import { z } from "zod"; // Matches the JSON Schema in SKILL_SPEC.md — kept in sync with marketplace/app/api/v1/ export const SkillFrontmatterSchema = z.object({ name: z .string() .regex( /^@[a-z0-9-]+\/[a-z0-9-]+$/, "name must be scoped @namespace/skill-name with only lowercase letters, digits, hyphens" ), version: z .string() .regex(/^\d+\.\d+\.\d+$/, "version must be semver MAJOR.MINOR.PATCH"), author: z.string().min(1).max(100), description: z.string().min(10).max(500), tags: z .array(z.string().regex(/^[a-z0-9-]+$/)) .max(10) .optional() .default([]), price: z.number().int().min(0), license: z.enum(["MIT", "Apache-2.0", "GPL-3.0", "proprietary"]).optional(), references: z .array( z.object({ url: z .string() .url() .regex( /^https:\/\/(((www\.)?paperclipskills\.com\/)|((www\.)?skills\.sh\/)|((raw\.)?githubusercontent\.com\/)|github\.com\/)/, "references must use a trusted registry: paperclipskills.com, skills.sh, or github.com" ), alias: z .string() .regex(/^[a-z][a-z0-9_-]*$/) .optional(), }) ) .max(20) .optional() .default([]), required_secrets: z .array(z.string().regex(/^[A-Z][A-Z0-9_]*$/)) .optional() .default([]), model_tier: z .enum(["any", "haiku", "sonnet", "opus"]) .optional() .default("any"), type: z.enum(["skill", "agent"]).optional().default("skill"), publisher_wallet: z .string() .regex(/^0x[0-9a-fA-F]{40}$/, "publisher_wallet must be a valid EVM address") .optional(), agent_config: z .object({ suggested_role: z.string().max(100).regex(/^[^\n\r\t]+$/, "suggested_role must not contain newlines or tabs").optional(), suggested_title: z.string().max(100).regex(/^[^\n\r\t]+$/, "suggested_title must not contain newlines or tabs").optional(), suggested_model: z.enum(["haiku", "sonnet", "opus"]).optional(), bundled_skills: z.array( z.string().regex( /^@[a-z0-9-]+\/[a-z0-9-]+$/, "bundled_skills items must be valid skill names @namespace/skill-name" ) ).optional(), }) .optional(), }); export type SkillFrontmatter = z.infer; // Secret scanning — reject skills with likely hardcoded secrets const SECRET_PATTERNS: Array<{ pattern: RegExp; label: string }> = [ { pattern: /sk-[a-zA-Z0-9]{20,}/, label: "OpenAI API key" }, { pattern: /xoxb-[a-zA-Z0-9-]+/, label: "Slack bot token" }, { pattern: /xoxp-[a-zA-Z0-9-]+/, label: "Slack user token" }, { pattern: /ghp_[a-zA-Z0-9]{36}/, label: "GitHub PAT" }, { pattern: /AKIA[A-Z0-9]{16}/, label: "AWS access key" }, { pattern: /AIza[0-9A-Za-z-_]{35}/, label: "Google API key" }, ]; export function scanForSecrets(content: string): string | null { for (const { pattern, label } of SECRET_PATTERNS) { if (pattern.test(content)) { return `Possible hardcoded ${label} detected. Use $ENV_VAR_NAME placeholders instead.`; } } return null; } export function parseNamespace(fullName: string): { namespace: string; skillName: string; } { const match = fullName.match(/^@([a-z0-9-]+)\/([a-z0-9-]+)$/); if (!match) throw new Error(`Invalid skill name: ${fullName}`); return { namespace: match[1], skillName: match[2] }; } export interface ValidationResult { valid: boolean; frontmatter?: SkillFrontmatter; body?: string; errors: string[]; warnings: string[]; } export function validateSkillContent(content: string): ValidationResult { const errors: string[] = []; const warnings: string[] = []; // Parse front-matter let parsed: { data: Record; content: string }; try { // Minimal inline parser to avoid requiring gray-matter at this layer const matter = require("gray-matter"); parsed = matter(content); } catch (err) { return { valid: false, errors: [`Failed to parse front-matter: ${err}`], warnings, }; } // Validate schema const result = SkillFrontmatterSchema.safeParse(parsed.data); if (!result.success) { const fieldErrors = result.error.flatten().fieldErrors; for (const [field, msgs] of Object.entries(fieldErrors)) { for (const msg of msgs ?? []) { errors.push(`[${field}] ${msg}`); } } return { valid: false, errors, warnings }; } const fm = result.data; // Secret scan const secretError = scanForSecrets(content); if (secretError) { errors.push(secretError); return { valid: false, errors, warnings }; } // Body content checks const body = parsed.content.trim(); if (!body) { errors.push("skill.md has no body content below the front-matter."); } else { if (!body.startsWith("#")) { warnings.push( "Body should start with a top-level heading (#) per SKILL_SPEC.md convention." ); } if (body.length > 100 * 1024) { errors.push("Body exceeds maximum 100KB limit."); } } // agent_config + type consistency if (fm.agent_config && fm.type !== "agent") { warnings.push( '`agent_config` is set but `type` is "skill". Set `type: "agent"` if this is an agent blueprint.' ); } // Price / license convention if (fm.price === 0 && fm.license === "proprietary") { warnings.push( 'Free skills conventionally use "MIT" license, not "proprietary".' ); } if (fm.price > 0 && fm.license && fm.license !== "proprietary") { warnings.push( 'Paid skills should use "proprietary" license per SKILL_SPEC.md.' ); } return { valid: errors.length === 0, frontmatter: fm, body, errors, warnings, }; }