/** * Cypher schema validation for the graph-mcp proxy. * * Tokenises a cypher string for labels (`:Label`) and relationship types * (`[:REL_TYPE]`, including `[r:TYPE]`, `[:TYPE*1..5]`, `[:A|B]`) and checks * each against a schema snapshot. Unknown tokens are returned with the * Levenshtein-nearest known tokens of the same kind as suggestions. * * Design posture: * - Defence layer, not a cypher correctness gate. Pass-through on any * unparseable pattern — Neo4j remains the syntax authority. * - Empty schema snapshot (cache not ready, or Neo4j unreachable at boot) * fails OPEN: ok=true, no rejections. The boot-race treatment is a * deliberate choice — refusing all cypher would wedge the admin agent * harder than the typo class this layer prevents. The caller emits * `validated=false` on the existing [graph-query] line so operators * still see the bypass. * - String literals are stripped before tokenisation to avoid matching * `:Foo` inside quoted content (e.g. `WHERE n.note CONTAINS ':Foo'`). */ export interface SchemaSnapshot { readonly labels: ReadonlySet; readonly relationshipTypes: ReadonlySet; } export interface UnknownToken { token: string; kind: "label" | "relationship"; nearest: string[]; hint: string; } export type ForbiddenKind = | "drop-database" | "create-index" | "create-constraint" | "call-dbms" | "call-db-create"; export interface ForbiddenPattern { kind: ForbiddenKind; match: string; hint: string; } export interface ValidationResult { ok: boolean; unknown: UnknownToken[]; forbidden: ForbiddenPattern[]; labelTokens: string[]; edgeTokens: string[]; } export interface ValidateOptions { /** "read" (default) skips DDL/admin forbidden checks. "write" applies them. */ mode?: "read" | "write"; } // DDL / admin patterns that the database-operator's raw write surface must // reject. The graph MCP shim is the only enforcement point — Neo4j Community // has no per-tool ACL, so the shim filters by syntactic shape before // forwarding the JSON-RPC line to the upstream cypher driver. Each pattern // is applied to a string-literal-stripped cypher body so that prose inside // quoted strings cannot trip a false positive. // // CALL apoc.* is intentionally NOT forbidden — apoc.refactor.mergeNodes is // the documented dedup primitive. CALL db.labels() and other read-only db.* // calls are allowed; only db.create* (createLabel, createProperty, // createRelationshipType) is forbidden because those are schema-mutating. const FORBIDDEN_PATTERNS: ReadonlyArray<{ kind: ForbiddenKind; pattern: RegExp; hint: string; }> = [ { kind: "drop-database", pattern: /\bDROP\s+(?:DATABASE|ALIAS|USER|ROLE)\b/i, hint: "DROP DATABASE/USER/ROLE/ALIAS is admin DDL — not permitted on the operator surface.", }, { kind: "create-index", pattern: /\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:RANGE\s+|TEXT\s+|POINT\s+|LOOKUP\s+|FULLTEXT\s+|BTREE\s+|VECTOR\s+)?INDEX\b/i, hint: "Index DDL is admin-owned (seed-neo4j.sh applies indexes). Not permitted on the operator surface.", }, { kind: "create-constraint", pattern: /\bCREATE\s+(?:OR\s+REPLACE\s+)?CONSTRAINT\b/i, hint: "Constraint DDL is admin-owned (seed-neo4j.sh applies constraints). Not permitted on the operator surface.", }, { kind: "call-dbms", pattern: /\bCALL\s+dbms\./i, hint: "CALL dbms.* is admin/security surface. Not permitted on the operator surface.", }, { kind: "call-db-create", pattern: /\bCALL\s+db\.create/i, hint: "CALL db.create* mutates schema (label/property/type registration). Not permitted on the operator surface.", }, ]; // Bracket content containing a type reference. Examples this matches: // [:PART_OF] // [r:PART_OF] // [:PART_OF*1..5] // [r:PART_OF*] // [:A|B] // The captured group is the TYPE(|TYPE)* alternation; the consumer splits it. const EDGE_PATTERN = /\[[^\]]*?:([A-Z_][A-Za-z0-9_]*(?:\|[A-Z_][A-Za-z0-9_]*)*)[^\]]*?\]/g; // Label reference in a node pattern. Applied to a remainder string that has // already had edge-bracket substrings stripped, so there is no overlap with // the edge pattern. The [A-Z] anchor excludes lowercase map keys. const LABEL_PATTERN = /:([A-Z][A-Za-z0-9_]*)/g; // Exported so the post-write audit (graph-write/audit.ts) can reuse the same // literal-stripping pass before its own static parsing — avoids duplicate // false-positive treatment across the two surfaces. export function stripStringLiterals(cypher: string): string { // Replace single- and double-quoted literals with empty quotes. Preserves // positional structure without retaining content that could match the // label regex (e.g. ':SomeLabel' inside a string). return cypher.replace(/'[^']*'|"[^"]*"/g, '""'); } function extractTokens(cypher: string): { labels: Set; edges: Set } { const cleaned = stripStringLiterals(cypher); const edges = new Set(); let match: RegExpExecArray | null; const edgePattern = new RegExp(EDGE_PATTERN.source, EDGE_PATTERN.flags); while ((match = edgePattern.exec(cleaned)) !== null) { for (const type of match[1].split("|")) { const clean = type.trim(); if (clean) edges.add(clean); } } const remainder = cleaned.replace(edgePattern, ""); const labels = new Set(); const labelPattern = new RegExp(LABEL_PATTERN.source, LABEL_PATTERN.flags); while ((match = labelPattern.exec(remainder)) !== null) { labels.add(match[1]); } return { labels, edges }; } function levenshtein(a: string, b: string): number { if (a === b) return 0; if (a.length === 0) return b.length; if (b.length === 0) return a.length; let prev = new Array(b.length + 1); let curr = new Array(b.length + 1); for (let j = 0; j <= b.length; j++) prev[j] = j; for (let i = 1; i <= a.length; i++) { curr[0] = i; for (let j = 1; j <= b.length; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost); } [prev, curr] = [curr, prev]; } return prev[b.length]; } function nearestMatches( token: string, known: ReadonlySet, limit = 3, ): string[] { if (known.size === 0) return []; const scored: Array<[string, number]> = []; for (const k of known) scored.push([k, levenshtein(token, k)]); scored.sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0])); return scored.slice(0, limit).map(([k]) => k); } function hintFor( token: string, kind: "label" | "relationship", nearest: string[], ): string { const didYouMean = nearest.length > 0 ? `Did you mean :${nearest[0]}? ` : ""; return `${didYouMean}Unknown ${kind} '${token}' — not in the current Neo4j schema. See .docs/neo4j.md for the canonical taxonomy.`; } function detectForbidden(cypher: string): ForbiddenPattern[] { // Strip literals so a property value like 'CREATE INDEX archive' cannot // trip a false rejection. The shared `stripStringLiterals` keeps this // treatment uniform with token extraction and audit static parsing. const cleaned = stripStringLiterals(cypher); const out: ForbiddenPattern[] = []; for (const { kind, pattern, hint } of FORBIDDEN_PATTERNS) { const found = cleaned.match(pattern); if (found) { out.push({ kind, match: found[0], hint }); } } return out; } export function validate( cypher: string, snapshot: SchemaSnapshot, options: ValidateOptions = {}, ): ValidationResult { const mode = options.mode ?? "read"; const forbidden = mode === "write" ? detectForbidden(cypher) : []; const { labels, edges } = extractTokens(cypher); // Fail-open when the snapshot is empty. An empty snapshot means "schema // cache not loaded" (boot race, Neo4j unreachable). Rejecting every token // would wedge the admin agent; letting it through preserves the observable // `validated=false` signal on the existing [graph-query] line. // Forbidden DDL/admin patterns still apply — those are syntactic, not // schema-derived. if (snapshot.labels.size === 0 && snapshot.relationshipTypes.size === 0) { return { ok: forbidden.length === 0, unknown: [], forbidden, labelTokens: [...labels], edgeTokens: [...edges], }; } const unknown: UnknownToken[] = []; for (const token of labels) { if (!snapshot.labels.has(token)) { const nearest = nearestMatches(token, snapshot.labels); unknown.push({ token, kind: "label", nearest, hint: hintFor(token, "label", nearest) }); } } for (const token of edges) { if (!snapshot.relationshipTypes.has(token)) { const nearest = nearestMatches(token, snapshot.relationshipTypes); unknown.push({ token, kind: "relationship", nearest, hint: hintFor(token, "relationship", nearest) }); } } // Write-mode caller treats `unknown` as a soft warning (audit), not a // hard reject — operators legitimately introduce new labels via REMOVE // n:Old SET n:New. Read-mode preserves prior behaviour: any unknown // → ok=false. const tokensOk = mode === "write" ? true : unknown.length === 0; return { ok: tokensOk && forbidden.length === 0, unknown, forbidden, labelTokens: [...labels], edgeTokens: [...edges], }; }