Skip to content

Commit d71b747

Browse files
committed
fix: harden VT fallback activation rules (#300) (thanks @superlowburn)
1 parent 4d211fc commit d71b747

File tree

3 files changed

+93
-25
lines changed

3 files changed

+93
-25
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
- Users: sync handle on ensure when GitHub login changes (#293) (thanks @christianhpoe).
2525
- Users/Auth: throttle GitHub profile sync on login; also sync avatar when it changes (#312) (thanks @ianalloway).
2626
- Upload gate: fetch GitHub account age by immutable account ID (prevents username swaps) (#116) (thanks @mkrokosz).
27+
- VT fallback: activate only VT-pending hidden skills when scans are unavailable/stale; keep quality/scanner-blocked skills hidden (#300) (thanks @superlowburn).
2728
- API: return proper status codes for delete/undelete errors (#35) (thanks @sergical).
2829
- API: for owners, return clearer status/messages for hidden/soft-deleted skills instead of a generic 404.
2930
- Web: allow copying OpenClaw scan summary text (thanks @borisolver, #322).

convex/vt.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { __test } from './vt'
3+
4+
describe('vt activation fallback', () => {
5+
it('activates only VT-pending hidden skills', () => {
6+
expect(
7+
__test.shouldActivateWhenVtUnavailable({
8+
moderationStatus: 'hidden',
9+
moderationReason: 'pending.scan',
10+
}),
11+
).toBe(true)
12+
13+
expect(
14+
__test.shouldActivateWhenVtUnavailable({
15+
moderationStatus: 'hidden',
16+
moderationReason: 'scanner.vt.pending',
17+
}),
18+
).toBe(true)
19+
20+
expect(
21+
__test.shouldActivateWhenVtUnavailable({
22+
moderationStatus: 'hidden',
23+
moderationReason: 'pending.scan.stale',
24+
}),
25+
).toBe(true)
26+
})
27+
28+
it('does not activate quality or scanner-hidden skills', () => {
29+
expect(
30+
__test.shouldActivateWhenVtUnavailable({
31+
moderationStatus: 'hidden',
32+
moderationReason: 'quality.low',
33+
}),
34+
).toBe(false)
35+
36+
expect(
37+
__test.shouldActivateWhenVtUnavailable({
38+
moderationStatus: 'hidden',
39+
moderationReason: 'scanner.llm.malicious',
40+
}),
41+
).toBe(false)
42+
})
43+
44+
it('does not activate blocked or already-active skills', () => {
45+
expect(
46+
__test.shouldActivateWhenVtUnavailable({
47+
moderationStatus: 'hidden',
48+
moderationReason: 'pending.scan',
49+
moderationFlags: ['blocked.malware'],
50+
}),
51+
).toBe(false)
52+
53+
expect(
54+
__test.shouldActivateWhenVtUnavailable({
55+
moderationStatus: 'active',
56+
moderationReason: 'pending.scan',
57+
}),
58+
).toBe(false)
59+
})
60+
})

convex/vt.ts

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { v } from 'convex/values'
22
import { internal } from './_generated/api'
33
import type { Id } from './_generated/dataModel'
4+
import type { ActionCtx } from './_generated/server'
45
import { action, internalAction, internalMutation } from './_generated/server'
56
import { buildDeterministicZip } from './lib/skillZip'
67

@@ -136,6 +137,13 @@ type PendingScanSkill = {
136137
checkCount: number
137138
}
138139

140+
type SkillActivationCandidate = {
141+
moderationStatus?: string
142+
moderationReason?: string
143+
moderationFlags?: string[]
144+
softDeletedAt?: number
145+
}
146+
139147
type PollPendingScansResult = {
140148
processed: number
141149
updated: number
@@ -228,6 +236,23 @@ type SyncModerationReasonsResult = {
228236
done: boolean
229237
}
230238

239+
const VT_PENDING_REASONS = new Set(['pending.scan', 'scanner.vt.pending', 'pending.scan.stale'])
240+
241+
function shouldActivateWhenVtUnavailable(skill: SkillActivationCandidate | null | undefined) {
242+
if (!skill || skill.softDeletedAt) return false
243+
if (skill.moderationFlags?.includes('blocked.malware')) return false
244+
if (skill.moderationStatus === 'active') return false
245+
const reason = skill.moderationReason
246+
return typeof reason === 'string' && VT_PENDING_REASONS.has(reason)
247+
}
248+
249+
async function activateSkillWhenVtUnavailable(ctx: ActionCtx, skillId: Id<'skills'>) {
250+
const skill = await ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId })
251+
if (!shouldActivateWhenVtUnavailable(skill)) return
252+
253+
await ctx.runMutation(internal.skills.setSkillModerationStatusActiveInternal, { skillId })
254+
}
255+
231256
export const fetchResults = action({
232257
args: {
233258
sha256hash: v.optional(v.string()),
@@ -306,19 +331,11 @@ export const scanWithVirusTotal = internalAction({
306331
const apiKey = process.env.VT_API_KEY
307332
if (!apiKey) {
308333
console.log('VT_API_KEY not configured, skipping scan — activating skill')
309-
// Activate the skill so it appears in search despite no VT scan.
310334
const version = await ctx.runQuery(internal.skills.getVersionByIdInternal, {
311335
versionId: args.versionId,
312336
})
313337
if (version) {
314-
const skill = await ctx.runQuery(internal.skills.getSkillByIdInternal, {
315-
skillId: version.skillId,
316-
})
317-
if (skill?.moderationReason !== 'quality.low') {
318-
await ctx.runMutation(internal.skills.setSkillModerationStatusActiveInternal, {
319-
skillId: version.skillId,
320-
})
321-
}
338+
await activateSkillWhenVtUnavailable(ctx, version.skillId)
322339
}
323340
return
324341
}
@@ -538,14 +555,7 @@ export const pollPendingScans = internalAction({
538555
versionId,
539556
vtAnalysis: { status: 'stale', checkedAt: Date.now() },
540557
})
541-
// Activate the skill so it appears in search — absence of a VT
542-
// verdict should not permanently hide a published skill.
543-
const skill = await ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId })
544-
if (skill?.moderationReason !== 'quality.low') {
545-
await ctx.runMutation(internal.skills.setSkillModerationStatusActiveInternal, {
546-
skillId,
547-
})
548-
}
558+
await activateSkillWhenVtUnavailable(ctx, skillId)
549559
staled++
550560
}
551561
continue
@@ -571,14 +581,7 @@ export const pollPendingScans = internalAction({
571581
versionId,
572582
vtAnalysis: { status: 'stale', checkedAt: Date.now() },
573583
})
574-
// Activate the skill so it appears in search — absence of a VT
575-
// verdict should not permanently hide a published skill.
576-
const skill = await ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId })
577-
if (skill?.moderationReason !== 'quality.low') {
578-
await ctx.runMutation(internal.skills.setSkillModerationStatusActiveInternal, {
579-
skillId,
580-
})
581-
}
584+
await activateSkillWhenVtUnavailable(ctx, skillId)
582585
staled++
583586
}
584587
continue
@@ -680,6 +683,10 @@ async function requestRescan(apiKey: string, sha256hash: string): Promise<boolea
680683
}
681684
}
682685

686+
export const __test = {
687+
shouldActivateWhenVtUnavailable,
688+
}
689+
683690
/**
684691
* Backfill function to process ALL pending skills at once
685692
* Run manually to clear backlog

0 commit comments

Comments
 (0)