SPB Git

spb/autoscholar

Public
5.6 KB · 125 lines typescript
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
Files · cbf31e64fd1db62a1819edf166ff2d903b6d3510
Menu

Theme