diff --git a/.gitignore b/.gitignore index 598e6be..6bb4aee 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ scripts/curation/data/* # !.claude/agents/ # !.claude/council_reasoning.md .claude/settings.json +.mcp.json diff --git a/apps/cli/package.json b/apps/cli/package.json index 0d3463e..f0f4a61 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "skillhub", - "version": "0.2.9", + "version": "0.2.11", "description": "CLI tool for managing AI Agent skills - search, install, and update skills for Claude, Codex, Copilot, and more", "author": "SkillHub Contributors", "license": "MIT", diff --git a/apps/cli/src/commands/install.ts b/apps/cli/src/commands/install.ts index 9f607c9..d64cb14 100644 --- a/apps/cli/src/commands/install.ts +++ b/apps/cli/src/commands/install.ts @@ -39,6 +39,14 @@ export async function install(skillId: string, options: InstallOptions): Promise try { skillInfo = await getSkill(skillId); if (skillInfo) { + // Block installation of malicious skills + if (skillInfo.isMalicious) { + spinner.fail(chalk.red('This skill has been flagged as malicious (contains malware).')); + console.log(chalk.red('Installation blocked for your safety.')); + console.log(chalk.dim(`See: https://skills.palebluedot.live/en/skill/${skillId}`)); + process.exit(1); + } + const reviewBadge = skillInfo.aiScore && skillInfo.reviewStatus === 'ai-reviewed' ? chalk.dim(` | AI: ${skillInfo.aiScore}/100`) : skillInfo.reviewStatus === 'verified' diff --git a/apps/cli/src/commands/search.ts b/apps/cli/src/commands/search.ts index c46709e..96a0be2 100644 --- a/apps/cli/src/commands/search.ts +++ b/apps/cli/src/commands/search.ts @@ -57,14 +57,15 @@ export async function search(query: string, options: SearchOptions): Promise 0 && skill.reviewStatus && skill.reviewStatus !== 'unreviewed' && skill.reviewStatus !== 'auto-scored'; - const aiPrefix = hasAiScore - ? `${chalk.magenta('AI')} ${(aiScore >= 75 ? chalk.green : aiScore >= 50 ? chalk.yellow : chalk.dim)(String(aiScore))} ` + const isAiReviewed = aiScore != null && aiScore > 0 && skill.reviewStatus && skill.reviewStatus !== 'unreviewed' && skill.reviewStatus !== 'auto-scored'; + const showAiScore = isAiReviewed; + const aiPrefix = showAiScore + ? `${chalk.magenta('AI')} ${(aiScore >= 75 ? chalk.green : aiScore >= 50 ? chalk.yellow : chalk.dim)(String(aiScore).padStart(2))} ` : ''; console.log( - ` ${aiPrefix}⬇ ${formatNumber(skill.downloadCount).padStart(6)} ⭐ ${formatNumber(skill.githubStars).padStart(6)} ${chalk.dim(skill.description.slice(0, hasAiScore ? 45 : 55))}${skill.description.length > (hasAiScore ? 45 : 55) ? '...' : ''}` + ` ${aiPrefix}⬇ ${formatNumber(skill.downloadCount).padStart(6)} ⭐ ${formatNumber(skill.githubStars).padStart(6)} ${chalk.dim(skill.description.slice(0, showAiScore ? 45 : 55))}${skill.description.length > (showAiScore ? 45 : 55) ? '...' : ''}` ); // Third line: Rating (only if ratingCount >= 3) diff --git a/apps/cli/src/utils/api.ts b/apps/cli/src/utils/api.ts index 35b7767..88c2b15 100644 --- a/apps/cli/src/utils/api.ts +++ b/apps/cli/src/utils/api.ts @@ -128,6 +128,7 @@ export interface SkillInfo { ratingCount?: number | null; reviewStatus?: string | null; aiScore?: number | null; + isMalicious?: boolean; isVerified: boolean; compatibility: { platforms: string[]; diff --git a/apps/web/app/[locale]/docs/cli/page.tsx b/apps/web/app/[locale]/docs/cli/page.tsx index 73dec07..6431c28 100644 --- a/apps/web/app/[locale]/docs/cli/page.tsx +++ b/apps/web/app/[locale]/docs/cli/page.tsx @@ -120,7 +120,7 @@ export default async function CliDocsPage({

{tContent('exSearchSort')}

- $ npx skillhub search "code review" --sort stars --limit 5 + $ npx skillhub search "code review" --sort aiScore --limit 5 # Sort options: recommended (default), aiScore, downloads, stars, rating, recent diff --git a/apps/web/app/[locale]/layout.tsx b/apps/web/app/[locale]/layout.tsx index 8ab1b18..3e675a3 100644 --- a/apps/web/app/[locale]/layout.tsx +++ b/apps/web/app/[locale]/layout.tsx @@ -5,6 +5,7 @@ import { notFound } from 'next/navigation'; import { locales, localeDirection, type Locale } from '@/i18n'; import { Providers } from '../providers'; import { Suspense } from 'react'; +import { SecurityAlertBanner } from '@/components/SecurityAlertBanner'; import { QueryNotification } from '@/components/QueryNotification'; import { ProgressBar } from '@/components/ProgressBar'; import '../globals.css'; @@ -68,6 +69,7 @@ export default async function LocaleLayout({ + diff --git a/apps/web/app/[locale]/malware/page.tsx b/apps/web/app/[locale]/malware/page.tsx new file mode 100644 index 0000000..6ea820c --- /dev/null +++ b/apps/web/app/[locale]/malware/page.tsx @@ -0,0 +1,149 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import Link from 'next/link'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { createDb, skillReviewQueries } from '@skillhub/db'; +import { getPageAlternates } from '@/lib/seo'; +import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; +import { ShieldAlert, Ban, ExternalLink } from 'lucide-react'; + +export const dynamic = 'force-dynamic'; + +async function getMalwareData(page: number, limit: number) { + try { + return await getOrSetCache(cacheKeys.malwarePage(page), cacheTTL.malware, async () => { + const db = createDb(); + const offset = (page - 1) * limit; + const [skills, total] = await Promise.all([ + skillReviewQueries.getMaliciousSkills(db, limit, offset), + skillReviewQueries.countMaliciousSkills(db), + ]); + return { skills, total }; + }); + } catch (error) { + console.error('Error fetching malware skills:', error); + return { skills: [], total: 0 }; + } +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + return { + alternates: getPageAlternates(locale, '/malware'), + }; +} + +export default async function MalwarePage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }>; + searchParams: Promise<{ page?: string }>; +}) { + const { locale } = await params; + const searchParamsResolved = await searchParams; + setRequestLocale(locale); + const t = await getTranslations('malwarePage'); + const tMalicious = await getTranslations('malicious'); + + const limit = 20; + const page = parseInt(searchParamsResolved.page || '1'); + const { skills: malwareSkills } = await getMalwareData(page, limit); + + return ( +
+
+
+ {/* Header */} +
+
+
+ +
+

{t('title')}

+

{t('subtitle')}

+
+
+ +
+
+ {malwareSkills.length === 0 ? ( +

{t('noResults')}

+ ) : ( +
+ {malwareSkills.map((skill) => ( +
+
+
+
+ + {skill.name || skill.id} + + + + {tMalicious('badge')} + +
+ +

+ {skill.githubOwner}/{skill.githubRepo} +

+ + {skill.description && ( +

+ {skill.description} +

+ )} + +
+ + + {t('downloadBlocked')} + + + + {t('installBlocked')} + +
+
+ + + GitHub + +
+
+ ))} +
+ )} + + {/* Back to stats link */} +
+ + ← {locale === 'fa' ? 'بازگشت به آمار بررسی‌ها' : 'Back to Review Statistics'} + +
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/reviewed/page.tsx b/apps/web/app/[locale]/reviewed/page.tsx index 413efc3..fc921f6 100644 --- a/apps/web/app/[locale]/reviewed/page.tsx +++ b/apps/web/app/[locale]/reviewed/page.tsx @@ -8,7 +8,8 @@ import { SkillCard } from '@/components/SkillCard'; import { ReviewedSortSelector } from '@/components/ReviewedSortSelector'; import { getPageAlternates } from '@/lib/seo'; import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; -import { Sparkles } from 'lucide-react'; +import { Sparkles, BarChart3 } from 'lucide-react'; +import Link from 'next/link'; // Force dynamic rendering to fetch fresh data from database @@ -95,9 +96,16 @@ export default async function ReviewedPage({

{t('title')}

{t('subtitle')}

-

+

{t('explanation')}

+ + + {locale === 'fa' ? 'مشاهده آمار بررسی‌ها' : 'View Review Statistics'} + diff --git a/apps/web/app/[locale]/reviewed/stats/page.tsx b/apps/web/app/[locale]/reviewed/stats/page.tsx new file mode 100644 index 0000000..4172842 --- /dev/null +++ b/apps/web/app/[locale]/reviewed/stats/page.tsx @@ -0,0 +1,224 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import Link from 'next/link'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { createDb, skillReviewQueries } from '@skillhub/db'; +import { getPageAlternates } from '@/lib/seo'; +import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; +import { BarChart3, ShieldAlert, AlertTriangle, CheckCircle } from 'lucide-react'; + +export const dynamic = 'force-dynamic'; + +interface StatsData { + pipeline: Record; + totalReviews: number; + scoreDistribution: Record; + securityStats: Record; + malwareCount: number; +} + +async function getPublicReviewStats(): Promise { + try { + return await getOrSetCache(cacheKeys.reviewStatsPublic(), cacheTTL.reviewStatsPublic, async () => { + const db = createDb(); + const [pipeline, totalReviews, scoreDistribution, securityStats, malwareCount] = await Promise.all([ + skillReviewQueries.getPublicPipelineStats(db), + skillReviewQueries.countTotalReviews(db), + skillReviewQueries.getScoreDistribution(db), + skillReviewQueries.getSecurityStats(db), + skillReviewQueries.countMaliciousSkills(db), + ]); + return { pipeline, totalReviews, scoreDistribution, securityStats, malwareCount }; + }); + } catch (error) { + console.error('Error fetching review stats:', error); + return null; + } +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + return { + alternates: getPageAlternates(locale, '/reviewed/stats'), + }; +} + +function StatCard({ + icon: Icon, + label, + value, + color, + href, +}: { + icon: React.ElementType; + label: string; + value: number; + color: string; + href?: string; +}) { + const content = ( +
+
+ +
+
+

{value.toLocaleString()}

+

{label}

+
+
+ ); + + if (href) { + return {content}; + } + return content; +} + +function BarRow({ + label, + value, + total, + color, +}: { + label: string; + value: number; + total: number; + color: string; +}) { + const pct = total > 0 ? (value / total) * 100 : 0; + return ( +
+
+ {label} + + {value.toLocaleString()} ({pct.toFixed(1)}%) + +
+
+
+
+
+ ); +} + +export default async function ReviewStatsPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('reviewStats'); + + const data = await getPublicReviewStats(); + + if (!data) { + return ( +
+
+
+

Failed to load stats.

+
+
+
+ ); + } + + // Total = all browse-ready SKILL.md skills (for meaningful percentages) + const pipelineTotal = Object.values(data.pipeline).reduce((sum, n) => sum + n, 0); + + const scoreTotal = + data.scoreDistribution.high + + data.scoreDistribution.mid + + data.scoreDistribution.low; + + return ( +
+
+
+ {/* Header Section */} +
+
+
+ +
+

{t('title')}

+

{t('subtitle')}

+
+
+ +
+
+ {/* Review Pipeline */} +
+

{t('pipelineTitle')}

+

{t('pipelineDescription')}

+
+ + +
+
+ {t('totalReviews')} + {data.totalReviews.toLocaleString()} +
+
+ + {/* Score Distribution */} +
+

{t('scoreTitle')}

+

{t('scoreDescription')}

+
+ + + +
+
+ + {/* Security & Malware */} +
+ {/* Security Scan */} +
+

{t('securityTitle')}

+

{t('securityDescription')}

+
+ + + +
+
+ + {/* Malware Detection */} +
+

{t('malwareTitle')}

+

{t('malwareDescription')}

+ + {data.malwareCount > 0 && ( + + {t('malwareViewAll')} → + + )} +
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/skill/[...id]/page.tsx b/apps/web/app/[locale]/skill/[...id]/page.tsx index a1cf673..3f2db22 100644 --- a/apps/web/app/[locale]/skill/[...id]/page.tsx +++ b/apps/web/app/[locale]/skill/[...id]/page.tsx @@ -4,7 +4,7 @@ import { notFound } from 'next/navigation'; import { headers } from 'next/headers'; import { Star, Download, Shield, CheckCircle, Copy, - ExternalLink, Github, Calendar, User, Tag, ChevronRight, Eye, Sparkles + ExternalLink, Github, Calendar, User, Tag, ChevronRight, Eye, Sparkles, XCircle, ShieldAlert } from 'lucide-react'; import { Header } from '@/components/Header'; import { Footer } from '@/components/Footer'; @@ -113,6 +113,8 @@ export default async function SkillPage({ params }: SkillPageProps) { notFound(); } + const isMalicious = dbSkill.isMalicious ?? false; + // Track view count with IP-based rate limiting (1 hour cooldown per IP) // Get client IP from headers (works with Cloudflare, nginx, etc.) const headersList = await headers(); @@ -159,7 +161,13 @@ export default async function SkillPage({ params }: SkillPageProps) { // Parse review notes for structured display const parsedNotes = review?.reviewNotes ? parseReviewNotes(review.reviewNotes) : null; - const hasReview = review && review.aiScore && (dbSkill.reviewStatus === 'ai-reviewed' || dbSkill.reviewStatus === 'verified'); + const hasReview = review && review.aiScore != null && (dbSkill.reviewStatus === 'ai-reviewed' || dbSkill.reviewStatus === 'verified'); + const isRejected = hasReview && review.aiScore === 0; + // Review is outdated only if both hashes are non-empty and differ + const reviewOutdated = hasReview + && review.contentHashAtReview && review.contentHashAtReview.length > 1 + && dbSkill.contentHash && dbSkill.contentHash.length > 1 + && dbSkill.contentHash !== review.contentHashAtReview; // Content section title based on source format (uses FORMAT_LABELS from skillhub-core) const getContentTitle = (format: string) => { @@ -358,12 +366,23 @@ export default async function SkillPage({ params }: SkillPageProps) { {/* Compact AI Review Score */} {hasReview && ( -
- = 75 ? 'text-success' : 'text-gold'}`} /> - = 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}> - {review.aiScore} - - {t('review.outOf100')} +
+ {isRejected ? ( + <> + + + {isRTL ? 'رد شده' : 'Rejected'} + + + ) : ( + <> + = 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-muted'}`} /> + = 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}> + {review.aiScore} + + {t('review.outOf100')} + + )}
)}
@@ -396,6 +415,25 @@ export default async function SkillPage({ params }: SkillPageProps) {
)} + {/* Malicious Skill Warning Banner */} + {isMalicious && ( +
+
+ +
+

+ {isRTL ? 'بدافزار شناسایی شد' : 'Malware Detected'} +

+

+ {isRTL + ? 'این مهارت به عنوان مخرب شناسایی شده است. شامل کد مبهم‌سازی شده برای دانلود و اجرای بارهای مضر است. دانلود فایل و نصب مسدود شده است.' + : 'This skill has been flagged as malicious. It contains obfuscated code designed to download and execute harmful payloads. File downloads and installation are blocked.'} +

+
+
+
+ )} + {/* Stale Skill Warning Banner */} {dbSkill.isStale && (
@@ -470,56 +508,87 @@ export default async function SkillPage({ params }: SkillPageProps) {
{/* Quick Install (Mobile) */}
- + {isMalicious ? ( +
+ +

+ {isRTL ? 'نصب مسدود شده — این مهارت حاوی بدافزار است' : 'Installation blocked — this skill contains malware'} +

+
+ ) : ( + + )}
{/* AI Review Card (Mobile) */} {hasReview && ( -
+

- + {isRejected ? : } {t('review.title')}

-
-
- - - - + {isRejected ? ( +
+
+
+ {isRTL ? 'رد شده' : 'Rejected'} +
+
+ {isRTL ? 'این مهارت معیارهای کیفیت را ندارد' : 'Does not meet quality standards'} +
+
+ {parsedNotes?.rationale && ( +

{parsedNotes.rationale}

+ )}
- {parsedNotes?.rationale && ( -

{parsedNotes.rationale}

- )} -
+ ) : ( +
+
+ + + + +
+ {parsedNotes?.rationale && ( +

{parsedNotes.rationale}

+ )} + {reviewOutdated && ( +

+ + {isRTL ? 'بررسی بر اساس نسخه قبلی' : 'Review based on previous version'} +

+ )} +
+ )}
)} @@ -547,61 +616,84 @@ export default async function SkillPage({ params }: SkillPageProps) {
{/* AI Review Card (Desktop) — above Install for visibility */} {hasReview && ( -
+

- + {isRejected ? : } {t('review.title')}

- {/* Overall Score */} -
-
= 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}> - {review.aiScore} -
-
{t('review.outOf100')}
-
- - {/* 4-axis score bars */} -
- - - - -
- - {/* Rationale */} - {parsedNotes?.rationale && ( -
-

{parsedNotes.rationale}

-
- )} - - {/* Tags: Audience, Maturity, Complexity, Use Cases */} - {(parsedNotes?.maturity || parsedNotes?.complexity || (parsedNotes?.audience && parsedNotes.audience.length > 0) || (parsedNotes?.useCases && parsedNotes.useCases.length > 0)) && ( -
-
- {parsedNotes?.maturity && ( - - {parsedNotes.maturity} - - )} - {parsedNotes?.complexity && ( - - {parsedNotes.complexity} - - )} - {parsedNotes?.audience?.map((a: string) => ( - - {a.trim()} - - ))} - {parsedNotes?.useCases?.map((uc: string) => ( - - {uc.trim()} - - ))} + {isRejected ? ( + <> + {/* Rejected status */} +
+
+ {isRTL ? 'رد شده' : 'Rejected'} +
+
+ {isRTL ? 'این مهارت معیارهای کیفیت را ندارد' : 'Does not meet quality standards'} +
-
+ + {/* Rationale for rejection */} + {parsedNotes?.rationale && ( +
+

{parsedNotes.rationale}

+
+ )} + + ) : ( + <> + {/* Overall Score */} +
+
= 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}> + {review.aiScore} +
+
{t('review.outOf100')}
+
+ + {/* 4-axis score bars */} +
+ + + + +
+ + {/* Rationale */} + {parsedNotes?.rationale && ( +
+

{parsedNotes.rationale}

+
+ )} + + {/* Tags: Audience, Maturity, Complexity, Use Cases */} + {(parsedNotes?.maturity || parsedNotes?.complexity || (parsedNotes?.audience && parsedNotes.audience.length > 0) || (parsedNotes?.useCases && parsedNotes.useCases.length > 0)) && ( +
+
+ {parsedNotes?.maturity && ( + + {parsedNotes.maturity} + + )} + {parsedNotes?.complexity && ( + + {parsedNotes.complexity} + + )} + {parsedNotes?.audience?.map((a: string) => ( + + {a.trim()} + + ))} + {parsedNotes?.useCases?.map((uc: string) => ( + + {uc.trim()} + + ))} +
+
+ )} + )} {/* Reviewer info */} @@ -611,40 +703,59 @@ export default async function SkillPage({ params }: SkillPageProps) { <> {t('review.reviewedOn', { date: new Date(review.reviewedAt).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US') })} )}
+ + {/* Outdated review warning */} + {!isRejected && reviewOutdated && ( +
+

+ + {isRTL ? 'بررسی بر اساس نسخه قبلی' : 'Review based on previous version'} +

+
+ )}
)} {/* Install Section */} - + {isMalicious ? ( +
+ +

+ {isRTL ? 'نصب مسدود شده — این مهارت حاوی بدافزار است' : 'Installation blocked — this skill contains malware'} +

+
+ ) : ( + + )}
diff --git a/apps/web/app/api/review/diagnose/route.ts b/apps/web/app/api/review/diagnose/route.ts new file mode 100644 index 0000000..422e59a --- /dev/null +++ b/apps/web/app/api/review/diagnose/route.ts @@ -0,0 +1,106 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { createDb, skillQueries, sql } from '@skillhub/db'; +import { requireAdmin } from '@/lib/admin-auth'; +import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; + +const db = createDb(); + +/** + * GET /api/review/diagnose?id=owner/repo/skill-name + * Returns which review pipeline filters a skill passes/fails. + * Calls the actual PostgreSQL raw_content_passes_prefilter function to detect + * discrepancies between JS approximation and SQL reality (e.g. invalid UTF-8). + * Admin-only endpoint for debugging why skills don't appear in pending list. + */ +export async function GET(request: NextRequest) { + const rateLimitResult = await withRateLimit(request, 'anonymous'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + const adminCheck = await requireAdmin(request); + if (!adminCheck.authorized) { + return adminCheck.response; + } + + try { + const { searchParams } = new URL(request.url); + const skillId = searchParams.get('id'); + + if (!skillId) { + return NextResponse.json({ error: 'Missing id parameter' }, { status: 400 }); + } + + const skill = await skillQueries.getById(db, skillId); + + if (!skill) { + return NextResponse.json({ error: 'Skill not found', id: skillId }, { status: 404 }); + } + + // Call the ACTUAL PostgreSQL function to check prefilter + // This catches UTF-8 issues that the JS approximation misses + let sqlPrefilterPass = false; + try { + const result = await db.execute( + sql`SELECT raw_content_passes_prefilter(raw_content) AS passes FROM skills WHERE id = ${skillId}` + ); + const row = [...result][0] as { passes?: boolean } | undefined; + sqlPrefilterPass = row?.passes === true; + } catch { + sqlPrefilterPass = false; + } + + // JS approximation for comparison + const rawContent = skill.rawContent ?? ''; + const contentLength = Buffer.byteLength(rawContent, 'utf8'); + const hasGeneratedComment = rawContent.includes('