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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- Indentation: 2 spaces, single quotes (Biome).
- Lint/format: Biome + oxlint (type-aware).
- Convex function names: verb-first (`getBySlug`, `publishVersion`).
- Prefer shared resource components/utilities for skills/souls/extensions to avoid duplicated logic.

## Testing Guidelines
- Framework: Vitest 4 + jsdom.
Expand All @@ -43,3 +44,12 @@
- New Convex functions must be pushed before `convex run`: use `bunx convex dev --once` (dev) or `bunx convex deploy` (prod).
- For non-interactive prod deploys, use `bunx convex deploy -y` to skip confirmation.
- If `bunx convex run --env-file .env.local ...` returns `401 MissingAccessToken` despite `bunx convex login`, workaround: omit `--env-file` and use `--deployment-name <name>` / `--prod`.

## Rewrite Notes (Resources, Moderation, Auth)
- Resources are unified in the `resources` table with `type` (`skill`, `soul`, `extension`); type tables keep version-specific fields.
- Shared UI pieces live in `src/components/ResourceCard.tsx`, `ResourceListRow.tsx`, and `ResourceDetailShell.tsx`.
- Moderation uses a reported-only queue + lookup tools; duplicate system removed. Similar-skill lookup uses `skills.findSimilarSkills` (vector search action).
- GitHub backups delete skill folders on hide/delete via `githubBackupsNode.deleteSkillBackupInternal`.
- Local auth bypass: set `AUTH_BYPASS=true` in Convex env + `VITE_AUTH_BYPASS=true` in `.env.local`.
- Local seed script: `bun run seed:local` (calls `devSeed:seedNixSkillsPublic`).
- Card stats labels: stars, downloads, installs (skills only), versions.
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@
### Fixed
- CLI sync: wrap note output to avoid terminal overflow; cap list lengths.
- CLI sync: label fallback scans as fallback locations.
- CLI package: bundle schema internally (no external `clawhub-schema` publish).
- Repo: mark `clawhub-schema` as private to prevent publishing.
- CLI package: bundle schema internally (no external `molthub-schema` publish).
- Repo: mark `molthub-schema` as private to prevent publishing.

## 0.0.2 - 2026-01-04

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ onlycrabs.ai: `https://onlycrabs.ai`
- Web app: TanStack Start (React, Vite/Nitro).
- Backend: Convex (DB + file storage + HTTP actions) + Convex Auth (GitHub OAuth).
- Search: OpenAI embeddings (`text-embedding-3-small`) + Convex vector search.
- API schema + routes: `packages/schema` (`clawhub-schema`).
- API schema + routes: `packages/schema` (`molthub-schema`).

## Telemetry

Expand Down
7 changes: 6 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"files": {
"includes": [
"**",
Expand Down Expand Up @@ -37,5 +37,10 @@
"semicolons": "asNeeded",
"trailingCommas": "all"
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
}
}
507 changes: 294 additions & 213 deletions bun.lock

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
*/

import type * as auth from "../auth.js";
import type * as automod from "../automod.js";
import type * as comments from "../comments.js";
import type * as crons from "../crons.js";
import type * as devSeed from "../devSeed.js";
import type * as devSeedExtra from "../devSeedExtra.js";
import type * as downloads from "../downloads.js";
import type * as extensions from "../extensions.js";
import type * as githubBackups from "../githubBackups.js";
import type * as githubBackupsNode from "../githubBackupsNode.js";
import type * as githubImport from "../githubImport.js";
Expand All @@ -34,6 +36,7 @@ import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
import type * as lib_leaderboards from "../lib/leaderboards.js";
import type * as lib_moderation from "../lib/moderation.js";
import type * as lib_public from "../lib/public.js";
import type * as lib_resource from "../lib/resource.js";
import type * as lib_searchText from "../lib/searchText.js";
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
import type * as lib_skillPublish from "../lib/skillPublish.js";
Expand All @@ -44,6 +47,7 @@ import type * as lib_soulPublish from "../lib/soulPublish.js";
import type * as lib_tokens from "../lib/tokens.js";
import type * as lib_webhooks from "../lib/webhooks.js";
import type * as maintenance from "../maintenance.js";
import type * as maintenanceMode from "../maintenanceMode.js";
import type * as rateLimits from "../rateLimits.js";
import type * as search from "../search.js";
import type * as seed from "../seed.js";
Expand All @@ -70,11 +74,13 @@ import type {

declare const fullApi: ApiFromModules<{
auth: typeof auth;
automod: typeof automod;
comments: typeof comments;
crons: typeof crons;
devSeed: typeof devSeed;
devSeedExtra: typeof devSeedExtra;
downloads: typeof downloads;
extensions: typeof extensions;
githubBackups: typeof githubBackups;
githubBackupsNode: typeof githubBackupsNode;
githubImport: typeof githubImport;
Expand All @@ -95,6 +101,7 @@ declare const fullApi: ApiFromModules<{
"lib/leaderboards": typeof lib_leaderboards;
"lib/moderation": typeof lib_moderation;
"lib/public": typeof lib_public;
"lib/resource": typeof lib_resource;
"lib/searchText": typeof lib_searchText;
"lib/skillBackfill": typeof lib_skillBackfill;
"lib/skillPublish": typeof lib_skillPublish;
Expand All @@ -105,6 +112,7 @@ declare const fullApi: ApiFromModules<{
"lib/tokens": typeof lib_tokens;
"lib/webhooks": typeof lib_webhooks;
maintenance: typeof maintenance;
maintenanceMode: typeof maintenanceMode;
rateLimits: typeof rateLimits;
search: typeof search;
seed: typeof seed;
Expand Down
263 changes: 263 additions & 0 deletions convex/automod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import { internalAction, internalMutation, internalQuery } from './_generated/server'

const CURSOR_KEY = 'skills'
const DEFAULT_BATCH_SIZE = 25
const MAX_BATCH_SIZE = 100
const DEFAULT_MAX_BATCHES = 4
const MAX_MAX_BATCHES = 10
const OPENAI_AUTOMOD_MODEL = process.env.OPENAI_AUTOMOD_MODEL ?? 'gpt-4.1-mini'

const SUSPICIOUS_EXTENSIONS = ['.exe', '.dll', '.bat', '.cmd', '.ps1', '.scr', '.com', '.msi']
const SUSPICIOUS_PHRASES: Array<{ label: string; pattern: RegExp }> = [
{ label: 'free-nitro', pattern: /free\s+nitro/i },
{ label: 'steam-gift', pattern: /steam\s+gift|free\s+steam\s+wallet/i },
{ label: 'token-grabber', pattern: /token\s+grabber|token\s+stealer/i },
{ label: 'wallet-seed', pattern: /seed\s+phrase|wallet\s+drainer|crypto\s+airdrop/i },
{ label: 'credential-harvest', pattern: /passwords?\s+stealer|credential\s+harvest/i },
{ label: 'piracy', pattern: /crack(ed)?\s+version|license\s+key\s+generator/i },
]

function clampInt(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}

function extractResponseText(payload: unknown) {
if (!payload || typeof payload !== 'object') return null
const output = (payload as { output?: unknown }).output
if (!Array.isArray(output)) return null
const chunks: string[] = []
for (const item of output) {
if (!item || typeof item !== 'object') continue
const content = (item as { content?: unknown }).content
if (!Array.isArray(content)) continue
for (const part of content) {
if (!part || typeof part !== 'object') continue
if ((part as { type?: unknown }).type !== 'output_text') continue
const text = (part as { text?: unknown }).text
if (typeof text === 'string' && text.trim()) chunks.push(text)
}
}
const joined = chunks.join('\n').trim()
return joined || null
}

function buildSkillText(skill: Doc<'skills'>, version: Doc<'skillVersions'> | null) {
const metadata = version?.parsed?.metadata ? JSON.stringify(version.parsed.metadata) : ''
const frontmatter = version?.parsed?.frontmatter ? JSON.stringify(version.parsed.frontmatter) : ''
const filePaths = version?.files?.map((file) => file.path).join('\n') ?? ''
return [skill.slug, skill.displayName, skill.summary ?? '', metadata, frontmatter, filePaths]
.filter(Boolean)
.join('\n')
}

function scanSkillLocally(skill: Doc<'skills'>, version: Doc<'skillVersions'> | null) {
const findings: string[] = []
const filePaths = version?.files?.map((file) => file.path.toLowerCase()) ?? []
const matchedExtensions = new Set<string>()

for (const filePath of filePaths) {
for (const extension of SUSPICIOUS_EXTENSIONS) {
if (filePath.endsWith(extension)) {
matchedExtensions.add(extension)
}
}
}

if (matchedExtensions.size > 0) {
findings.push(`bundled executables (${Array.from(matchedExtensions).join(', ')})`)
}

const text = buildSkillText(skill, version)
for (const rule of SUSPICIOUS_PHRASES) {
if (rule.pattern.test(text)) {
findings.push(`phrase:${rule.label}`)
}
}

return findings
}

async function classifyWithOpenAI(args: {
skill: Doc<'skills'>
version: Doc<'skillVersions'> | null
}) {
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) return null

const input = [
`Skill: ${args.skill.slug}`,
`Display name: ${args.skill.displayName}`,
args.skill.summary ? `Summary: ${args.skill.summary}` : null,
`Latest version: ${args.version?.version ?? 'unknown'}`,
args.version?.files?.length
? `Files: ${args.version.files
.map((file) => file.path)
.slice(0, 80)
.join(', ')}`
: 'Files: none',
]
.filter(Boolean)
.join('\n')

const response = await fetch('https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: OPENAI_AUTOMOD_MODEL,
instructions:
'You are helping a marketplace moderator detect malware/scams in skill bundles. ' +
'Return a JSON object with {"flag": boolean, "reason": string}. ' +
'Flag true only if the input strongly suggests malware/scams (executables, credential theft, scams). ' +
'Keep reason short and factual. Return only JSON.',
input,
max_output_tokens: 120,
}),
})

if (!response.ok) return null
const payload = (await response.json()) as unknown
const text = extractResponseText(payload)
if (!text) return null
try {
const parsed = JSON.parse(text) as { flag?: boolean; reason?: string }
if (typeof parsed.flag !== 'boolean') return null
return parsed
} catch {
return null
}
}

export const getSkillAutomodCursorInternal = internalQuery({
args: {},
handler: async (ctx) => {
const cursor = await ctx.db
.query('automodCursors')
.withIndex('by_key', (q) => q.eq('key', CURSOR_KEY))
.unique()
return cursor?.cursorUpdatedAt ?? null
},
})

export const setSkillAutomodCursorInternal = internalMutation({
args: { cursorUpdatedAt: v.number() },
handler: async (ctx, args) => {
const existing = await ctx.db
.query('automodCursors')
.withIndex('by_key', (q) => q.eq('key', CURSOR_KEY))
.unique()
if (existing) {
await ctx.db.patch(existing._id, {
cursorUpdatedAt: args.cursorUpdatedAt,
updatedAt: Date.now(),
})
return
}
await ctx.db.insert('automodCursors', {
key: CURSOR_KEY,
cursorUpdatedAt: args.cursorUpdatedAt,
updatedAt: Date.now(),
})
},
})

export const getSkillAutomodBatchInternal = internalQuery({
args: {
cursorUpdatedAt: v.optional(v.number()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = clampInt(args.limit ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
const cursor = args.cursorUpdatedAt ?? 0
return ctx.db
.query('skills')
.withIndex('by_updated', (q) => (cursor ? q.gt('updatedAt', cursor) : q))
.order('asc')
.take(limit)
},
})

export const runSkillAutomodInternal: ReturnType<typeof internalAction> = internalAction({
args: {
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
},
handler: async (
ctx,
args,
): Promise<
| { ok: true; processed: number; reported: number; cursorUpdatedAt: number }
| { ok: false; reason: string }
> => {
const automodUserId = process.env.AUTOMOD_USER_ID as Id<'users'> | undefined
if (!automodUserId) {
return { ok: false as const, reason: 'AUTOMOD_USER_ID not configured' }
}

const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES)
let cursorUpdatedAt: number =
(await ctx.runQuery(internal.automod.getSkillAutomodCursorInternal, {})) ?? 0

let batches = 0
let processed = 0
let reported = 0

while (batches < maxBatches) {
const skills = (await ctx.runQuery(internal.automod.getSkillAutomodBatchInternal, {
cursorUpdatedAt,
limit: batchSize,
})) as Doc<'skills'>[]

if (skills.length === 0) break

for (const skill of skills) {
if (skill.softDeletedAt) {
cursorUpdatedAt = Math.max(cursorUpdatedAt, skill.updatedAt)
continue
}

const version = skill.latestVersionId
? ((await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: skill.latestVersionId,
})) as Doc<'skillVersions'> | null)
: null

const findings = scanSkillLocally(skill, version)
let reason: string | null = null

if (findings.length > 0) {
reason = `Automod heuristic flagged: ${findings.join(', ')}`
} else {
const aiResult = await classifyWithOpenAI({ skill, version })
if (aiResult?.flag) {
reason = `Automod AI flagged: ${aiResult.reason ?? 'suspicious content'}`
}
}

if (reason) {
const result = (await ctx.runMutation(internal.skills.reportInternal, {
skillId: skill._id,
userId: automodUserId,
reason,
})) as { reported: boolean }
if (result.reported) reported += 1
}

cursorUpdatedAt = Math.max(cursorUpdatedAt, skill.updatedAt)
processed += 1
}

await ctx.runMutation(internal.automod.setSkillAutomodCursorInternal, { cursorUpdatedAt })
batches += 1
if (skills.length < batchSize) break
}

return { ok: true as const, processed, reported, cursorUpdatedAt }
},
})
Loading