spb/autoscholar
Public
1import { createHash } from "node:crypto";2import { INSTRUCTION_MAX_BYTES } from "@autoscholar/schemas";34export interface InstructionValidation {5 ok: boolean;6 sha256: string;7 byteSize: number;8 errors: string[];9 warnings: string[];10 frontmatter: Record<string, unknown> | null;11 headings: string[];12 sections: { present: string[]; missing: string[] };13 text: string;14}1516/** Sections a good research CLAUDE.md should carry (docs/RESEARCH_INSTRUCTIONS_GUIDE.md). Missing ones are warnings, not errors. */17export const RECOMMENDED_SECTIONS: Array<{ key: string; patterns: RegExp[] }> = [18 { key: "objective", patterns: [/objective|research question|goal|scope/i] },19 { key: "domain-mode", patterns: [/domain|discipline|mode|methodolog/i] },20 { key: "contribution", patterns: [/contribution|novelty/i] },21 { key: "literature", patterns: [/literature|sources?|prior art|related work/i] },22 { key: "evidence", patterns: [/data|simulation|proof|evidence|corpus/i] },23 { key: "procedure", patterns: [/procedure|experiment|analysis|protocol|derivation/i] },24 { key: "robustness", patterns: [/robustness|baseline|falsif|sensitivity/i] },25 { key: "reproducibility", patterns: [/reproduc|artifact/i] },26 { key: "manuscript", patterns: [/manuscript|latex|paper structure|writing/i] },27 { key: "review", patterns: [/review|revision|quality/i] },28 { key: "limitations-ethics", patterns: [/limitation|ethic|legal|constraint/i] },29 { key: "acceptance", patterns: [/acceptance|success criteria|done when/i] },30];3132/** Patterns that must never appear in a research instruction (they target the platform, not the research). */33const FORBIDDEN_PATTERNS: Array<{ re: RegExp; reason: string }> = [34 { re: /\bsudo\b/i, reason: "privilege escalation (sudo)" },35 { re: /\bssh\b\s+[\w.@-]+/i, reason: "remote shell access" },36 { re: /ANTHROPIC_API_KEY|api[_ -]?key\s*[:=]/i, reason: "credential handling" },37 { re: /\b(launchctl|pmctl|pfctl|dscl|sysadminctl)\b/i, reason: "system administration commands" },38 {39 re: /\bignore (all |any )?(previous|platform|system) (instructions|policy|rules)/i,40 reason: "attempt to override platform policy",41 },42 {43 re: /\b(bypassPermissions|dangerously-skip-permissions|allowDangerouslySkipPermissions)\b/i,44 reason: "permission bypass",45 },46 { re: /\/etc\/autoscholar|\/var\/autoscholar\/(?!runs\/)/i, reason: "platform paths" },47 { re: /\.ssh\/|id_ed25519|id_rsa|authorized_keys/i, reason: "SSH key material" },48];4950// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are exactly what we reject51const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/;52const PATH_TRAVERSAL = /(^|[\s"'`(])\.\.\/(\.\.\/)+/;5354export function parseFrontmatter(text: string): {55 frontmatter: Record<string, unknown> | null;56 body: string;57} {58 const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);59 if (!m) return { frontmatter: null, body: text };60 const fm: Record<string, unknown> = {};61 for (const line of (m[1] ?? "").split(/\r?\n/)) {62 const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);63 if (!kv) continue;64 const raw = (kv[2] ?? "").trim();65 let val: unknown = raw.replace(/^["']|["']$/g, "");66 if (raw.startsWith("[") && raw.endsWith("]")) {67 val = raw68 .slice(1, -1)69 .split(",")70 .map((s) => s.trim().replace(/^["']|["']$/g, ""))71 .filter(Boolean);72 } else if (raw === "true" || raw === "false") val = raw === "true";73 else if (/^-?\d+(\.\d+)?$/.test(raw)) val = Number(raw);74 fm[kv[1] ?? ""] = val;75 }76 return { frontmatter: fm, body: text.slice(m[0].length) };77}7879export function validateInstruction(bytes: Buffer, filename = "CLAUDE.md"): InstructionValidation {80 const errors: string[] = [];81 const warnings: string[] = [];82 const sha256 = createHash("sha256").update(bytes).digest("hex");83 const byteSize = bytes.length;84 if (byteSize === 0) errors.push("file is empty");85 if (byteSize > INSTRUCTION_MAX_BYTES) errors.push(`file exceeds ${INSTRUCTION_MAX_BYTES} bytes`);86 if (!/\.(md|txt)$/i.test(filename)) errors.push("only .md or .txt files are accepted");87 if (/[\\/]/.test(filename) || filename.includes(".."))88 errors.push("filename must not contain path separators");89 let text = "";90 try {91 text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);92 } catch {93 errors.push("file is not valid UTF-8");94 }95 if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);96 if (CONTROL_CHARS.test(text)) errors.push("control characters are not allowed");97 if (PATH_TRAVERSAL.test(text)) warnings.push("contains path traversal sequences (../../)");98 for (const f of FORBIDDEN_PATTERNS) if (f.re.test(text)) errors.push(`forbidden content: ${f.reason}`);99 const { frontmatter, body } = parseFrontmatter(text);100 const headings = [...body.matchAll(/^#{1,4}\s+(.+?)\s*$/gm)].map((m) => (m[1] ?? "").trim());101 if (headings.length === 0)102 warnings.push("no Markdown headings found; the instruction may be hard to follow");103 const words = body.split(/\s+/).filter(Boolean).length;104 if (words < 80) warnings.push(`very short instruction (${words} words); consider a fuller research brief`);105 const present: string[] = [];106 const missing: string[] = [];107 for (const s of RECOMMENDED_SECTIONS) {108 const hit =109 headings.some((h) => s.patterns.some((p) => p.test(h))) || s.patterns.some((p) => p.test(body));110 (hit ? present : missing).push(s.key);111 }112 if (missing.length > 0) warnings.push(`recommended sections missing: ${missing.join(", ")}`);113 return {114 ok: errors.length === 0,115 sha256,116 byteSize,117 errors,118 warnings,119 frontmatter,120 headings,121 sections: { present, missing },122 text,123 };124}125