Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
- Users: sync handle on ensure when GitHub login changes (#293) (thanks @christianhpoe).
- Users/Auth: throttle GitHub profile sync on login; also sync avatar when it changes (#312) (thanks @ianalloway).
- Upload gate: fetch GitHub account age by immutable account ID (prevents username swaps) (#116) (thanks @mkrokosz).
- VT fallback: activate only VT-pending hidden skills when scans are unavailable/stale; keep quality/scanner-blocked skills hidden (#300) (thanks @superlowburn).
- API: return proper status codes for delete/undelete errors (#35) (thanks @sergical).
- API: for owners, return clearer status/messages for hidden/soft-deleted skills instead of a generic 404.
- Web: allow copying OpenClaw scan summary text (thanks @borisolver, #322).
Expand Down
28 changes: 28 additions & 0 deletions convex/lib/public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,32 @@ describe('public skill mapping', () => {
comments: 0,
})
})

it('returns skill when moderationStatus is active', () => {
const skill = makeSkill({ moderationStatus: 'active' })
expect(toPublicSkill(skill)).not.toBeNull()
})

it('filters out skill when moderationStatus is hidden', () => {
const skill = makeSkill({ moderationStatus: 'hidden' })
expect(toPublicSkill(skill)).toBeNull()
})

it('returns skill when moderationStatus is undefined (legacy)', () => {
const skill = makeSkill({ moderationStatus: undefined as unknown as string })
expect(toPublicSkill(skill)).not.toBeNull()
})

it('filters out soft-deleted skills', () => {
const skill = makeSkill({ softDeletedAt: Date.now() })
expect(toPublicSkill(skill)).toBeNull()
})

it('filters out skills with blocked.malware flag', () => {
const skill = makeSkill({
moderationStatus: 'active',
moderationFlags: ['blocked.malware'],
})
expect(toPublicSkill(skill)).toBeNull()
})
})
60 changes: 60 additions & 0 deletions convex/vt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { __test } from './vt'

describe('vt activation fallback', () => {
it('activates only VT-pending hidden skills', () => {
expect(
__test.shouldActivateWhenVtUnavailable({
moderationStatus: 'hidden',
moderationReason: 'pending.scan',
}),
).toBe(true)

expect(
__test.shouldActivateWhenVtUnavailable({
moderationStatus: 'hidden',
moderationReason: 'scanner.vt.pending',
}),
).toBe(true)

expect(
__test.shouldActivateWhenVtUnavailable({
moderationStatus: 'hidden',
moderationReason: 'pending.scan.stale',
}),
).toBe(true)
})

it('does not activate quality or scanner-hidden skills', () => {
expect(
__test.shouldActivateWhenVtUnavailable({
moderationStatus: 'hidden',
moderationReason: 'quality.low',
}),
).toBe(false)

expect(
__test.shouldActivateWhenVtUnavailable({
moderationStatus: 'hidden',
moderationReason: 'scanner.llm.malicious',
}),
).toBe(false)
})

it('does not activate blocked or already-active skills', () => {
expect(
__test.shouldActivateWhenVtUnavailable({
moderationStatus: 'hidden',
moderationReason: 'pending.scan',
moderationFlags: ['blocked.malware'],
}),
).toBe(false)

expect(
__test.shouldActivateWhenVtUnavailable({
moderationStatus: 'active',
moderationReason: 'pending.scan',
}),
).toBe(false)
})
})
39 changes: 38 additions & 1 deletion convex/vt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { Id } from './_generated/dataModel'
import type { ActionCtx } from './_generated/server'
import { action, internalAction, internalMutation } from './_generated/server'
import { buildDeterministicZip } from './lib/skillZip'

Expand Down Expand Up @@ -136,6 +137,13 @@ type PendingScanSkill = {
checkCount: number
}

type SkillActivationCandidate = {
moderationStatus?: string
moderationReason?: string
moderationFlags?: string[]
softDeletedAt?: number
}

type PollPendingScansResult = {
processed: number
updated: number
Expand Down Expand Up @@ -228,6 +236,23 @@ type SyncModerationReasonsResult = {
done: boolean
}

const VT_PENDING_REASONS = new Set(['pending.scan', 'scanner.vt.pending', 'pending.scan.stale'])

function shouldActivateWhenVtUnavailable(skill: SkillActivationCandidate | null | undefined) {
if (!skill || skill.softDeletedAt) return false
if (skill.moderationFlags?.includes('blocked.malware')) return false
if (skill.moderationStatus === 'active') return false
const reason = skill.moderationReason
return typeof reason === 'string' && VT_PENDING_REASONS.has(reason)
}

async function activateSkillWhenVtUnavailable(ctx: ActionCtx, skillId: Id<'skills'>) {
const skill = await ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId })
if (!shouldActivateWhenVtUnavailable(skill)) return

await ctx.runMutation(internal.skills.setSkillModerationStatusActiveInternal, { skillId })
}

export const fetchResults = action({
args: {
sha256hash: v.optional(v.string()),
Expand Down Expand Up @@ -305,7 +330,13 @@ export const scanWithVirusTotal = internalAction({
handler: async (ctx, args) => {
const apiKey = process.env.VT_API_KEY
if (!apiKey) {
console.log('VT_API_KEY not configured, skipping scan')
console.log('VT_API_KEY not configured, skipping scan — activating skill')
const version = await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: args.versionId,
})
if (version) {
await activateSkillWhenVtUnavailable(ctx, version.skillId)
}
Comment on lines +333 to +339
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same quality gate bypass - check moderationReason before activating

Suggested change
console.log('VT_API_KEY not configured, skipping scan — activating skill')
// Activate the skill so it appears in search despite no VT scan.
const version = await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: args.versionId,
})
if (version) {
await ctx.runMutation(internal.skills.setSkillModerationStatusActiveInternal, {
skillId: version.skillId,
})
}
console.log('VT_API_KEY not configured, skipping scan — activating skill')
// Activate the skill so it appears in search despite no VT scan.
const version = await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: args.versionId,
})
if (version) {
const skill = await ctx.runQuery(internal.skills.getSkillByIdInternal, {
skillId: version.skillId,
})
if (skill?.moderationReason !== 'quality.low') {
await ctx.runMutation(internal.skills.setSkillModerationStatusActiveInternal, {
skillId: version.skillId,
})
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: convex/vt.ts
Line: 308:317

Comment:
same quality gate bypass - check `moderationReason` before activating

```suggestion
      console.log('VT_API_KEY not configured, skipping scan — activating skill')
      // Activate the skill so it appears in search despite no VT scan.
      const version = await ctx.runQuery(internal.skills.getVersionByIdInternal, {
        versionId: args.versionId,
      })
      if (version) {
        const skill = await ctx.runQuery(internal.skills.getSkillByIdInternal, {
          skillId: version.skillId,
        })
        if (skill?.moderationReason !== 'quality.low') {
          await ctx.runMutation(internal.skills.setSkillModerationStatusActiveInternal, {
            skillId: version.skillId,
          })
        }
      }
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit a8e62e2. Same fix applied — added skill lookup and moderationReason check before activating. This prevents quality gate quarantine bypass in the non-VT-API path.

return
}

Expand Down Expand Up @@ -524,6 +555,7 @@ export const pollPendingScans = internalAction({
versionId,
vtAnalysis: { status: 'stale', checkedAt: Date.now() },
})
await activateSkillWhenVtUnavailable(ctx, skillId)
staled++
}
continue
Expand All @@ -549,6 +581,7 @@ export const pollPendingScans = internalAction({
versionId,
vtAnalysis: { status: 'stale', checkedAt: Date.now() },
})
await activateSkillWhenVtUnavailable(ctx, skillId)
staled++
}
continue
Expand Down Expand Up @@ -650,6 +683,10 @@ async function requestRescan(apiKey: string, sha256hash: string): Promise<boolea
}
}

export const __test = {
shouldActivateWhenVtUnavailable,
}

/**
* Backfill function to process ALL pending skills at once
* Run manually to clear backlog
Expand Down