diff --git a/artifacts/api-server/src/lib/aiAnalysis.ts b/artifacts/api-server/src/lib/aiAnalysis.ts index 5caebcc..54af14c 100644 --- a/artifacts/api-server/src/lib/aiAnalysis.ts +++ b/artifacts/api-server/src/lib/aiAnalysis.ts @@ -229,6 +229,28 @@ function buildRuleMenu(aiRules: AiRuleConfig[]): string { ].join("\n"); } +export async function generateSkillDescription( + provider: AiProvider, + prompts: Prompt[], + files: ParsedFile[], +): Promise { + const descriptionPrompt = + prompts.find((p) => p.key === "description")?.content ?? ""; + if (!descriptionPrompt) return null; + const systemPrompt = prompts.find((p) => p.key === "system")?.content ?? ""; + const payload = buildSkillPayload(files); + const user = `${descriptionPrompt}\n\nHier ist das zu beschreibende Skill:\n${payload}`; + try { + const content = await callProvider(provider, systemPrompt, user); + const parsed = extractJson(content) as { description?: unknown }; + const description = + typeof parsed.description === "string" ? parsed.description.trim() : ""; + return description ? description.slice(0, 2000) : null; + } catch { + return null; + } +} + export async function runAiAnalysis( provider: AiProvider, prompts: Prompt[], diff --git a/artifacts/api-server/src/lib/scanEngine.ts b/artifacts/api-server/src/lib/scanEngine.ts index 92e23a7..f87c95e 100644 --- a/artifacts/api-server/src/lib/scanEngine.ts +++ b/artifacts/api-server/src/lib/scanEngine.ts @@ -19,7 +19,11 @@ import type { FindingCounts as DbFindingCounts, ScanCheckpoint, } from "@workspace/db"; -import { runAiAnalysis, type AiRuleConfig } from "./aiAnalysis"; +import { + runAiAnalysis, + generateSkillDescription, + type AiRuleConfig, +} from "./aiAnalysis"; export type { ScanCheckpoint } from "@workspace/db"; @@ -47,6 +51,7 @@ export type EngineResult = { verdict: "pass" | "review" | "block"; aiUsed: boolean; aiError: string | null; + aiDescription: string | null; }; export function computeCounts(findings: RawFinding[]): DbFindingCounts { @@ -149,6 +154,7 @@ export async function analyzeSkill( let aiUsed = false; let aiError: string | null = null; let aiFindings: RawFinding[] = []; + let aiDescription: string | null = null; if (useAi) { await onProgress?.({ type: "ai-start" }); @@ -173,6 +179,15 @@ export async function analyzeSkill( .where(eq(aiProvidersTable.enabled, true)) .limit(1); + const prompts: Prompt[] = await db.select().from(promptsTable); + + // The skill description is generated independently of the AI findings + // rules: it only needs a configured provider with a token, and a failure + // here must never break the rest of the scan. + if (provider?.apiToken) { + aiDescription = await generateSkillDescription(provider, prompts, files); + } + if (!aiRulesEnabled || enabledAiRules.length === 0) { aiError = "KI-Regeln sind im Regelwerk deaktiviert."; } else if (!provider) { @@ -181,7 +196,6 @@ export async function analyzeSkill( } else if (!provider.apiToken) { aiError = `Für den Provider "${provider.name}" ist kein API-Token hinterlegt.`; } else { - const prompts: Prompt[] = await db.select().from(promptsTable); const result = await runAiAnalysis( provider, prompts, @@ -244,5 +258,6 @@ export async function analyzeSkill( verdict, aiUsed, aiError, + aiDescription, }; } diff --git a/artifacts/api-server/src/lib/seed.ts b/artifacts/api-server/src/lib/seed.ts index 1ede8cc..6746a1b 100644 --- a/artifacts/api-server/src/lib/seed.ts +++ b/artifacts/api-server/src/lib/seed.ts @@ -15,6 +15,12 @@ const DEFAULT_PROMPTS = [ content: 'Analysiere das folgende Skill auf verdeckte oder subtile Risiken, die einer reinen Mustererkennung entgehen: versteckte Prompt-Injektionen, manipulative Anweisungen, Täuschung des Nutzers, schädliche Code-Absichten, Datenabfluss und Datenschutzverstöße (DSGVO). Gib das Ergebnis als JSON in genau diesem Format zurück: {"findings": [{"axis": "security|privacy", "severity": "critical|high|medium|low|info", "title": "kurzer Titel", "description": "Beschreibung des Risikos", "remediation": "Empfehlung", "file": "Dateipfad oder null", "line": Zeilennummer oder null, "snippet": "relevanter Ausschnitt oder null"}]}. Wenn keine Risiken gefunden werden, gib {"findings": []} zurück. Antworte auf Deutsch.', }, + { + key: "description", + name: "Beschreibungs-Anweisung", + content: + 'Beschreibe sachlich und neutral, wozu dieses Skill dient und wie es grob funktioniert ("Was macht dieser Skill?"). Fasse Zweck und Funktionsweise in wenigen kurzen Sätzen zusammen, ohne Risiken zu bewerten oder Empfehlungen zu geben. Gib das Ergebnis als JSON in genau diesem Format zurück: {"description": "kurze, sachliche Beschreibung in wenigen Sätzen"}. Antworte auf Deutsch.', + }, ]; export async function seedDefaults(): Promise { diff --git a/artifacts/api-server/src/routes/scans.ts b/artifacts/api-server/src/routes/scans.ts index 29e1748..80dd13c 100644 --- a/artifacts/api-server/src/routes/scans.ts +++ b/artifacts/api-server/src/routes/scans.ts @@ -40,6 +40,7 @@ export function serializeScan(scan: Scan) { return { id: scan.id, name: scan.name, + description: scan.description, source: scan.source, status: scan.status, verdict: scan.verdict, @@ -347,6 +348,7 @@ async function persistScan( .insert(scansTable) .values({ name, + description: result.aiDescription, source: input.source, status: "completed", verdict: result.verdict, diff --git a/artifacts/skillguard/public/opengraph.jpg b/artifacts/skillguard/public/opengraph.jpg index 84f5f15..a40c476 100644 Binary files a/artifacts/skillguard/public/opengraph.jpg and b/artifacts/skillguard/public/opengraph.jpg differ diff --git a/artifacts/skillguard/src/pages/scan-report.tsx b/artifacts/skillguard/src/pages/scan-report.tsx index f617e53..e5d6fd7 100644 --- a/artifacts/skillguard/src/pages/scan-report.tsx +++ b/artifacts/skillguard/src/pages/scan-report.tsx @@ -16,7 +16,7 @@ import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers"; import { formatDate } from "@/lib/format"; -import { ShieldQuestion, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical } from "lucide-react"; +import { ShieldQuestion, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles } from "lucide-react"; import type { ScanDetail } from "@workspace/api-client-react"; export default function ScanReport() { @@ -143,6 +143,21 @@ export default function ScanReport() { )} + {data.description && ( + + + + + Was macht dieser Skill? + + KI-generierte Beschreibung des Zwecks und der Funktionsweise. + + +

{data.description}

+
+
+ )} +
@@ -644,6 +659,13 @@ function buildReportHtml(data: ScanDetail): string { ? `
KI-Analyse nicht durchgeführt: ${escapeHtml(data.aiError)}. Die statische Analyse wurde dennoch abgeschlossen.
` : ""; + const descriptionSection = data.description + ? ` +

Was macht dieser Skill?

+

KI-generierte Beschreibung des Zwecks und der Funktionsweise.

+

${escapeHtml(data.description)}

` + : ""; + const checkpointsSection = data.checkpoints && data.checkpoints.length > 0 ? `

Prüfschritte (${data.checkpoints.length})

@@ -702,6 +724,7 @@ function buildReportHtml(data: ScanDetail): string { .snippet { background: #0f172a; color: #f1f5f9; padding: 8px 10px; border-radius: 4px; font-family: "Courier New", monospace; font-size: 11px; white-space: pre-wrap; word-break: break-word; } .remediation { background: #ecfdf5; border: 1px solid #a7f3d0; border-radius: 4px; padding: 8px 10px; margin-top: 8px; } .empty { color: #475569; } + .description { border: 1px solid #e9d5ff; background: #faf5ff; border-radius: 4px; padding: 10px 12px; margin: 8px 0; white-space: pre-wrap; } .footer { margin-top: 24px; color: #94a3b8; font-size: 10px; border-top: 1px solid #e2e8f0; padding-top: 8px; } @media print { body { margin: 0; } } @@ -715,6 +738,8 @@ function buildReportHtml(data: ScanDetail): string { ${aiWarning} + ${descriptionSection} +

Risiko-Score

diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index d178789..8423c20 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -101,6 +101,11 @@ export interface FindingCounts { export interface Scan { id: number; name: string; + /** + * AI-generated summary of the skill's purpose (null when no AI description is available) + * @nullable + */ + description?: string | null; source: ScanSource; status: ScanStatus; verdict: ScanVerdict; diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 4945f90..0e4361d 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -468,6 +468,9 @@ components: type: integer name: type: string + description: + type: ["string", "null"] + description: AI-generated summary of the skill's purpose (null when no AI description is available) source: type: string enum: [zip, file, text] diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index b3a4224..0f66f2a 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -43,6 +43,7 @@ export const GetDashboardResponse = zod.object({ "recentScans": zod.array(zod.object({ "id": zod.number(), "name": zod.string(), + "description": zod.string().nullish().describe('AI-generated summary of the skill\'s purpose (null when no AI description is available)'), "source": zod.enum(['zip', 'file', 'text']), "status": zod.enum(['completed', 'failed']), "verdict": zod.enum(['pass', 'review', 'block']), @@ -81,6 +82,7 @@ export const GetDashboardResponse = zod.object({ export const ListScansResponseItem = zod.object({ "id": zod.number(), "name": zod.string(), + "description": zod.string().nullish().describe('AI-generated summary of the skill\'s purpose (null when no AI description is available)'), "source": zod.enum(['zip', 'file', 'text']), "status": zod.enum(['completed', 'failed']), "verdict": zod.enum(['pass', 'review', 'block']), @@ -200,6 +202,7 @@ export const GetScanParams = zod.object({ export const GetScanResponse = zod.object({ "id": zod.number(), "name": zod.string(), + "description": zod.string().nullish().describe('AI-generated summary of the skill\'s purpose (null when no AI description is available)'), "source": zod.enum(['zip', 'file', 'text']), "status": zod.enum(['completed', 'failed']), "verdict": zod.enum(['pass', 'review', 'block']), diff --git a/lib/api-zod/src/generated/types/scan.ts b/lib/api-zod/src/generated/types/scan.ts index 78004d2..597d946 100644 --- a/lib/api-zod/src/generated/types/scan.ts +++ b/lib/api-zod/src/generated/types/scan.ts @@ -14,6 +14,11 @@ import type { ScanVerdict } from './scanVerdict'; export interface Scan { id: number; name: string; + /** + * AI-generated summary of the skill's purpose (null when no AI description is available) + * @nullable + */ + description?: string | null; source: ScanSource; status: ScanStatus; verdict: ScanVerdict; diff --git a/lib/db/src/schema/scans.ts b/lib/db/src/schema/scans.ts index ed58be9..ab387a2 100644 --- a/lib/db/src/schema/scans.ts +++ b/lib/db/src/schema/scans.ts @@ -42,6 +42,7 @@ export const scansTable = pgTable( { id: serial("id").primaryKey(), name: text("name").notNull(), + description: text("description"), source: text("source").notNull(), status: text("status").notNull().default("completed"), verdict: text("verdict").notNull().default("pass"),