import { createHash } from "node:crypto"; import { INSTRUCTION_MAX_BYTES } from "@autoscholar/schemas"; export interface InstructionValidation { ok: boolean; sha256: string; byteSize: number; errors: string[]; warnings: string[]; frontmatter: Record | null; headings: string[]; sections: { present: string[]; missing: string[] }; text: string; } /** Sections a good research CLAUDE.md should carry (docs/RESEARCH_INSTRUCTIONS_GUIDE.md). Missing ones are warnings, not errors. */ export const RECOMMENDED_SECTIONS: Array<{ key: string; patterns: RegExp[] }> = [ { key: "objective", patterns: [/objective|research question|goal|scope/i] }, { key: "domain-mode", patterns: [/domain|discipline|mode|methodolog/i] }, { key: "contribution", patterns: [/contribution|novelty/i] }, { key: "literature", patterns: [/literature|sources?|prior art|related work/i] }, { key: "evidence", patterns: [/data|simulation|proof|evidence|corpus/i] }, { key: "procedure", patterns: [/procedure|experiment|analysis|protocol|derivation/i] }, { key: "robustness", patterns: [/robustness|baseline|falsif|sensitivity/i] }, { key: "reproducibility", patterns: [/reproduc|artifact/i] }, { key: "manuscript", patterns: [/manuscript|latex|paper structure|writing/i] }, { key: "review", patterns: [/review|revision|quality/i] }, { key: "limitations-ethics", patterns: [/limitation|ethic|legal|constraint/i] }, { key: "acceptance", patterns: [/acceptance|success criteria|done when/i] }, ]; /** Patterns that must never appear in a research instruction (they target the platform, not the research). */ const FORBIDDEN_PATTERNS: Array<{ re: RegExp; reason: string }> = [ { re: /\bsudo\b/i, reason: "privilege escalation (sudo)" }, { re: /\bssh\b\s+[\w.@-]+/i, reason: "remote shell access" }, { re: /ANTHROPIC_API_KEY|api[_ -]?key\s*[:=]/i, reason: "credential handling" }, { re: /\b(launchctl|pmctl|pfctl|dscl|sysadminctl)\b/i, reason: "system administration commands" }, { re: /\bignore (all |any )?(previous|platform|system) (instructions|policy|rules)/i, reason: "attempt to override platform policy", }, { re: /\b(bypassPermissions|dangerously-skip-permissions|allowDangerouslySkipPermissions)\b/i, reason: "permission bypass", }, { re: /\/etc\/autoscholar|\/var\/autoscholar\/(?!runs\/)/i, reason: "platform paths" }, { re: /\.ssh\/|id_ed25519|id_rsa|authorized_keys/i, reason: "SSH key material" }, ]; // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are exactly what we reject const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/; const PATH_TRAVERSAL = /(^|[\s"'`(])\.\.\/(\.\.\/)+/; export function parseFrontmatter(text: string): { frontmatter: Record | null; body: string; } { const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); if (!m) return { frontmatter: null, body: text }; const fm: Record = {}; for (const line of (m[1] ?? "").split(/\r?\n/)) { const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); if (!kv) continue; const raw = (kv[2] ?? "").trim(); let val: unknown = raw.replace(/^["']|["']$/g, ""); if (raw.startsWith("[") && raw.endsWith("]")) { val = raw .slice(1, -1) .split(",") .map((s) => s.trim().replace(/^["']|["']$/g, "")) .filter(Boolean); } else if (raw === "true" || raw === "false") val = raw === "true"; else if (/^-?\d+(\.\d+)?$/.test(raw)) val = Number(raw); fm[kv[1] ?? ""] = val; } return { frontmatter: fm, body: text.slice(m[0].length) }; } export function validateInstruction(bytes: Buffer, filename = "CLAUDE.md"): InstructionValidation { const errors: string[] = []; const warnings: string[] = []; const sha256 = createHash("sha256").update(bytes).digest("hex"); const byteSize = bytes.length; if (byteSize === 0) errors.push("file is empty"); if (byteSize > INSTRUCTION_MAX_BYTES) errors.push(`file exceeds ${INSTRUCTION_MAX_BYTES} bytes`); if (!/\.(md|txt)$/i.test(filename)) errors.push("only .md or .txt files are accepted"); if (/[\\/]/.test(filename) || filename.includes("..")) errors.push("filename must not contain path separators"); let text = ""; try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { errors.push("file is not valid UTF-8"); } if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); if (CONTROL_CHARS.test(text)) errors.push("control characters are not allowed"); if (PATH_TRAVERSAL.test(text)) warnings.push("contains path traversal sequences (../../)"); for (const f of FORBIDDEN_PATTERNS) if (f.re.test(text)) errors.push(`forbidden content: ${f.reason}`); const { frontmatter, body } = parseFrontmatter(text); const headings = [...body.matchAll(/^#{1,4}\s+(.+?)\s*$/gm)].map((m) => (m[1] ?? "").trim()); if (headings.length === 0) warnings.push("no Markdown headings found; the instruction may be hard to follow"); const words = body.split(/\s+/).filter(Boolean).length; if (words < 80) warnings.push(`very short instruction (${words} words); consider a fuller research brief`); const present: string[] = []; const missing: string[] = []; for (const s of RECOMMENDED_SECTIONS) { const hit = headings.some((h) => s.patterns.some((p) => p.test(h))) || s.patterns.some((p) => p.test(body)); (hit ? present : missing).push(s.key); } if (missing.length > 0) warnings.push(`recommended sections missing: ${missing.join(", ")}`); return { ok: errors.length === 0, sha256, byteSize, errors, warnings, frontmatter, headings, sections: { present, missing }, text, }; }