@rubytech/create-maxy 1.0.743 → 1.0.745
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate-write.test.d.ts +2 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate-write.test.d.ts.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate-write.test.js +97 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate-write.test.js.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.d.ts +37 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.d.ts.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.js +333 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.js.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.d.ts +71 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.d.ts.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.js +168 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.js.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-validate.d.ts +13 -1
- package/payload/platform/lib/graph-mcp/dist/cypher-validate.d.ts.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/cypher-validate.js +70 -3
- package/payload/platform/lib/graph-mcp/dist/cypher-validate.js.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/index.js +297 -11
- package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts +3 -6
- package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/schema-cache.js +30 -7
- package/payload/platform/lib/graph-mcp/dist/schema-cache.js.map +1 -1
- package/payload/platform/lib/graph-mcp/src/__tests__/cypher-validate-write.test.ts +150 -0
- package/payload/platform/lib/graph-mcp/src/cypher-rewrite-stamp.ts +349 -0
- package/payload/platform/lib/graph-mcp/src/cypher-shim-write.ts +240 -0
- package/payload/platform/lib/graph-mcp/src/cypher-validate.ts +95 -3
- package/payload/platform/lib/graph-mcp/src/index.ts +415 -18
- package/payload/platform/lib/graph-mcp/src/schema-cache.ts +37 -7
- package/payload/platform/lib/graph-write/dist/__tests__/audit.test.d.ts +2 -0
- package/payload/platform/lib/graph-write/dist/__tests__/audit.test.d.ts.map +1 -0
- package/payload/platform/lib/graph-write/dist/__tests__/audit.test.js +147 -0
- package/payload/platform/lib/graph-write/dist/__tests__/audit.test.js.map +1 -0
- package/payload/platform/lib/graph-write/dist/audit.d.ts +84 -0
- package/payload/platform/lib/graph-write/dist/audit.d.ts.map +1 -0
- package/payload/platform/lib/graph-write/dist/audit.js +129 -0
- package/payload/platform/lib/graph-write/dist/audit.js.map +1 -0
- package/payload/platform/lib/graph-write/dist/index.d.ts +1 -0
- package/payload/platform/lib/graph-write/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-write/dist/index.js +18 -22
- package/payload/platform/lib/graph-write/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-write/src/__tests__/audit.test.ts +162 -0
- package/payload/platform/lib/graph-write/src/audit.ts +182 -0
- package/payload/platform/lib/graph-write/src/index.ts +5 -0
- package/payload/platform/package.json +2 -2
- package/payload/platform/plugins/docs/references/deployment.md +2 -1
- package/payload/platform/plugins/docs/references/memory-guide.md +2 -0
- package/payload/platform/plugins/docs/references/troubleshooting.md +16 -0
- package/payload/platform/templates/specialists/agents/database-operator.md +20 -6
- package/payload/server/chunk-2T4RRIJK.js +9462 -0
- package/payload/server/chunk-SPTD7L7Z.js +9474 -0
- package/payload/server/maxy-edge.js +94 -16
- package/payload/server/public/assets/{graph-DhNy70eS.js → graph-BoSJpLG3.js} +1 -1
- package/payload/server/public/graph.html +1 -1
- package/payload/server/server.js +1 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the shim-owned write_neo4j_cypher path (Task 797).
|
|
3
|
+
* Extracted from index.ts so the executeWrite tx body, response synthesis,
|
|
4
|
+
* and value serialization can be unit-tested without the shim's top-level
|
|
5
|
+
* process bootstrap (which spawns uvx and opens stdin/stdout pipes).
|
|
6
|
+
*
|
|
7
|
+
* The shim's index.ts wires these up: rewriter → shared driver → session.
|
|
8
|
+
* executeWrite(tx => runWriteTxBody(...)) → response synth → stdout.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const ORPHAN_CHECK_CYPHER =
|
|
12
|
+
"MATCH (n) WHERE n.createdBySession = $__autoSession " +
|
|
13
|
+
"AND n.createdAt >= $__autoStartTimestamp " +
|
|
14
|
+
"AND NOT (n)--() " +
|
|
15
|
+
"RETURN collect(elementId(n)) AS orphanIds, " +
|
|
16
|
+
"collect(distinct labels(n)) AS labelSets";
|
|
17
|
+
|
|
18
|
+
export interface GraphTx {
|
|
19
|
+
run(
|
|
20
|
+
cypher: string,
|
|
21
|
+
params?: Record<string, unknown>,
|
|
22
|
+
): Promise<{
|
|
23
|
+
records: Array<{
|
|
24
|
+
keys: readonly string[];
|
|
25
|
+
get: (k: string | number) => unknown;
|
|
26
|
+
}>;
|
|
27
|
+
summary: {
|
|
28
|
+
counters: {
|
|
29
|
+
updates(): {
|
|
30
|
+
nodesCreated: number;
|
|
31
|
+
relationshipsCreated: number;
|
|
32
|
+
propertiesSet: number;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
}>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface GraphSession {
|
|
40
|
+
executeWrite<T>(work: (tx: GraphTx) => Promise<T>): Promise<T>;
|
|
41
|
+
close(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GraphDriver {
|
|
45
|
+
session(): GraphSession;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class OrphanRollbackError extends Error {
|
|
49
|
+
constructor(
|
|
50
|
+
public readonly orphanIds: string[],
|
|
51
|
+
public readonly sampleLabels: string[],
|
|
52
|
+
) {
|
|
53
|
+
super(`orphan rollback: ${orphanIds.length} unattached node(s)`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AutoContext {
|
|
58
|
+
agent: string;
|
|
59
|
+
session: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WriteTxOutcome {
|
|
63
|
+
nodesCreated: number;
|
|
64
|
+
relsCreated: number;
|
|
65
|
+
propertiesSet: number;
|
|
66
|
+
serializedRecords: Array<Record<string, unknown>>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function serializeValue(v: unknown): unknown {
|
|
70
|
+
if (v === null || v === undefined) return v;
|
|
71
|
+
if (typeof v === "string" || typeof v === "boolean" || typeof v === "number") {
|
|
72
|
+
return v;
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(v)) return v.map(serializeValue);
|
|
75
|
+
if (typeof v === "object") {
|
|
76
|
+
const obj = v as Record<string, unknown>;
|
|
77
|
+
if (
|
|
78
|
+
"low" in obj &&
|
|
79
|
+
"high" in obj &&
|
|
80
|
+
typeof obj.low === "number" &&
|
|
81
|
+
typeof obj.high === "number" &&
|
|
82
|
+
Object.keys(obj).length === 2
|
|
83
|
+
) {
|
|
84
|
+
if (obj.high === 0 || obj.high === -1) return obj.low;
|
|
85
|
+
return String(obj);
|
|
86
|
+
}
|
|
87
|
+
if ("properties" in obj && "labels" in obj) {
|
|
88
|
+
return {
|
|
89
|
+
labels: obj.labels,
|
|
90
|
+
elementId: obj.elementId,
|
|
91
|
+
properties: serializeValue(obj.properties),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if ("properties" in obj && "type" in obj && "elementId" in obj) {
|
|
95
|
+
return {
|
|
96
|
+
type: obj.type,
|
|
97
|
+
elementId: obj.elementId,
|
|
98
|
+
properties: serializeValue(obj.properties),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (
|
|
102
|
+
obj.constructor &&
|
|
103
|
+
obj.constructor.name &&
|
|
104
|
+
obj.constructor.name !== "Object" &&
|
|
105
|
+
obj.constructor.name !== "Array"
|
|
106
|
+
) {
|
|
107
|
+
return String(obj);
|
|
108
|
+
}
|
|
109
|
+
const out: Record<string, unknown> = {};
|
|
110
|
+
for (const [k, val] of Object.entries(obj)) {
|
|
111
|
+
out[k] = serializeValue(val);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
return v;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Body of the executeWrite tx. Runs the rewritten cypher, captures counters,
|
|
120
|
+
* runs the orphan check, throws OrphanRollbackError if any orphans remain
|
|
121
|
+
* (executeWrite catches the throw and rolls back the entire tx).
|
|
122
|
+
*
|
|
123
|
+
* Caller is responsible for: (a) opening the session, (b) wrapping this in
|
|
124
|
+
* `session.executeWrite(tx => runWriteTxBody(tx, ...))`, (c) calling close()
|
|
125
|
+
* in finally, (d) translating the outcome / OrphanRollbackError into the
|
|
126
|
+
* synthesised MCP response and audit log lines.
|
|
127
|
+
*/
|
|
128
|
+
export async function runWriteTxBody(
|
|
129
|
+
tx: GraphTx,
|
|
130
|
+
rewrittenCypher: string,
|
|
131
|
+
operatorParams: Record<string, unknown>,
|
|
132
|
+
autoCtx: AutoContext,
|
|
133
|
+
): Promise<WriteTxOutcome> {
|
|
134
|
+
const tsRes = await tx.run("RETURN datetime() AS now");
|
|
135
|
+
const startTs = tsRes.records[0].get("now");
|
|
136
|
+
|
|
137
|
+
const merged: Record<string, unknown> = {
|
|
138
|
+
...operatorParams,
|
|
139
|
+
__autoStartTimestamp: startTs,
|
|
140
|
+
__autoAgent: autoCtx.agent,
|
|
141
|
+
__autoSession: autoCtx.session,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const writeRes = await tx.run(rewrittenCypher, merged);
|
|
145
|
+
const counters = writeRes.summary.counters.updates();
|
|
146
|
+
const nodesCreated = counters.nodesCreated;
|
|
147
|
+
const relsCreated = counters.relationshipsCreated;
|
|
148
|
+
const propertiesSet = counters.propertiesSet;
|
|
149
|
+
const keys =
|
|
150
|
+
writeRes.records.length > 0 ? writeRes.records[0].keys.map(String) : [];
|
|
151
|
+
const serializedRecords = writeRes.records.map((rec) => {
|
|
152
|
+
const out: Record<string, unknown> = {};
|
|
153
|
+
for (const k of keys) {
|
|
154
|
+
out[k] = serializeValue(rec.get(k));
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const orphanRes = await tx.run(ORPHAN_CHECK_CYPHER, merged);
|
|
160
|
+
const orphanIds = (orphanRes.records[0].get("orphanIds") as string[]) ?? [];
|
|
161
|
+
const labelSets =
|
|
162
|
+
(orphanRes.records[0].get("labelSets") as string[][]) ?? [];
|
|
163
|
+
if (orphanIds.length > 0) {
|
|
164
|
+
const flatLabels = Array.from(new Set(labelSets.flat()));
|
|
165
|
+
throw new OrphanRollbackError(orphanIds, flatLabels);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { nodesCreated, relsCreated, propertiesSet, serializedRecords };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface WriteSynthInput {
|
|
172
|
+
nodesCreated: number;
|
|
173
|
+
relsCreated: number;
|
|
174
|
+
propertiesSet: number;
|
|
175
|
+
records: Array<Record<string, unknown>>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function synthesiseWriteResponse(
|
|
179
|
+
id: string | number,
|
|
180
|
+
w: WriteSynthInput,
|
|
181
|
+
): string {
|
|
182
|
+
const parts: string[] = [];
|
|
183
|
+
if (w.records.length > 0) {
|
|
184
|
+
parts.push(
|
|
185
|
+
`${w.records.length} record${w.records.length === 1 ? "" : "s"} returned`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
parts.push(`${w.nodesCreated} nodes created`);
|
|
189
|
+
parts.push(`${w.relsCreated} relationships created`);
|
|
190
|
+
if (w.propertiesSet > 0) parts.push(`${w.propertiesSet} properties set`);
|
|
191
|
+
if (w.records.length > 0) {
|
|
192
|
+
parts.push("");
|
|
193
|
+
parts.push(JSON.stringify(w.records, null, 2));
|
|
194
|
+
}
|
|
195
|
+
return JSON.stringify({
|
|
196
|
+
jsonrpc: "2.0",
|
|
197
|
+
id,
|
|
198
|
+
result: {
|
|
199
|
+
content: [{ type: "text", text: parts.join("\n") }],
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function synthesiseOrphanRollback(
|
|
205
|
+
id: string | number,
|
|
206
|
+
orphanCount: number,
|
|
207
|
+
sampleLabels: string[],
|
|
208
|
+
): string {
|
|
209
|
+
const labelLine =
|
|
210
|
+
sampleLabels.length > 0
|
|
211
|
+
? sampleLabels.slice(0, 5).join(", ")
|
|
212
|
+
: "(unknown — orphans had no labels)";
|
|
213
|
+
const text =
|
|
214
|
+
`orphan rollback — ${orphanCount} node(s) created without an edge in the same transaction.\n` +
|
|
215
|
+
`Sample labels: ${labelLine}.\n\n` +
|
|
216
|
+
`The transaction was aborted; nothing was persisted. Anchor each new node to its semantic parent in the same statement (Graph Stewardship Doctrine Rule 1 in database-operator.md).`;
|
|
217
|
+
return JSON.stringify({
|
|
218
|
+
jsonrpc: "2.0",
|
|
219
|
+
id,
|
|
220
|
+
result: {
|
|
221
|
+
content: [{ type: "text", text }],
|
|
222
|
+
isError: true,
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function synthesiseShimError(
|
|
228
|
+
id: string | number,
|
|
229
|
+
prefix: string,
|
|
230
|
+
errMsg: string,
|
|
231
|
+
): string {
|
|
232
|
+
return JSON.stringify({
|
|
233
|
+
jsonrpc: "2.0",
|
|
234
|
+
id,
|
|
235
|
+
result: {
|
|
236
|
+
content: [{ type: "text", text: `${prefix}: ${errMsg}` }],
|
|
237
|
+
isError: true,
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
@@ -31,13 +31,75 @@ export interface UnknownToken {
|
|
|
31
31
|
hint: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export type ForbiddenKind =
|
|
35
|
+
| "drop-database"
|
|
36
|
+
| "create-index"
|
|
37
|
+
| "create-constraint"
|
|
38
|
+
| "call-dbms"
|
|
39
|
+
| "call-db-create";
|
|
40
|
+
|
|
41
|
+
export interface ForbiddenPattern {
|
|
42
|
+
kind: ForbiddenKind;
|
|
43
|
+
match: string;
|
|
44
|
+
hint: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
export interface ValidationResult {
|
|
35
48
|
ok: boolean;
|
|
36
49
|
unknown: UnknownToken[];
|
|
50
|
+
forbidden: ForbiddenPattern[];
|
|
37
51
|
labelTokens: string[];
|
|
38
52
|
edgeTokens: string[];
|
|
39
53
|
}
|
|
40
54
|
|
|
55
|
+
export interface ValidateOptions {
|
|
56
|
+
/** "read" (default) skips DDL/admin forbidden checks. "write" applies them. */
|
|
57
|
+
mode?: "read" | "write";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// DDL / admin patterns that the database-operator's raw write surface must
|
|
61
|
+
// reject. The graph MCP shim is the only enforcement point — Neo4j Community
|
|
62
|
+
// has no per-tool ACL, so the shim filters by syntactic shape before
|
|
63
|
+
// forwarding the JSON-RPC line to the upstream cypher driver. Each pattern
|
|
64
|
+
// is applied to a string-literal-stripped cypher body so that prose inside
|
|
65
|
+
// quoted strings cannot trip a false positive.
|
|
66
|
+
//
|
|
67
|
+
// CALL apoc.* is intentionally NOT forbidden — apoc.refactor.mergeNodes is
|
|
68
|
+
// the documented dedup primitive. CALL db.labels() and other read-only db.*
|
|
69
|
+
// calls are allowed; only db.create* (createLabel, createProperty,
|
|
70
|
+
// createRelationshipType) is forbidden because those are schema-mutating.
|
|
71
|
+
const FORBIDDEN_PATTERNS: ReadonlyArray<{
|
|
72
|
+
kind: ForbiddenKind;
|
|
73
|
+
pattern: RegExp;
|
|
74
|
+
hint: string;
|
|
75
|
+
}> = [
|
|
76
|
+
{
|
|
77
|
+
kind: "drop-database",
|
|
78
|
+
pattern: /\bDROP\s+(?:DATABASE|ALIAS|USER|ROLE)\b/i,
|
|
79
|
+
hint: "DROP DATABASE/USER/ROLE/ALIAS is admin DDL — not permitted on the operator surface.",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
kind: "create-index",
|
|
83
|
+
pattern: /\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:RANGE\s+|TEXT\s+|POINT\s+|LOOKUP\s+|FULLTEXT\s+|BTREE\s+|VECTOR\s+)?INDEX\b/i,
|
|
84
|
+
hint: "Index DDL is admin-owned (seed-neo4j.sh applies indexes). Not permitted on the operator surface.",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
kind: "create-constraint",
|
|
88
|
+
pattern: /\bCREATE\s+(?:OR\s+REPLACE\s+)?CONSTRAINT\b/i,
|
|
89
|
+
hint: "Constraint DDL is admin-owned (seed-neo4j.sh applies constraints). Not permitted on the operator surface.",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
kind: "call-dbms",
|
|
93
|
+
pattern: /\bCALL\s+dbms\./i,
|
|
94
|
+
hint: "CALL dbms.* is admin/security surface. Not permitted on the operator surface.",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
kind: "call-db-create",
|
|
98
|
+
pattern: /\bCALL\s+db\.create/i,
|
|
99
|
+
hint: "CALL db.create* mutates schema (label/property/type registration). Not permitted on the operator surface.",
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
|
|
41
103
|
// Bracket content containing a type reference. Examples this matches:
|
|
42
104
|
// [:PART_OF]
|
|
43
105
|
// [r:PART_OF]
|
|
@@ -52,7 +114,10 @@ const EDGE_PATTERN = /\[[^\]]*?:([A-Z_][A-Za-z0-9_]*(?:\|[A-Z_][A-Za-z0-9_]*)*)[
|
|
|
52
114
|
// the edge pattern. The [A-Z] anchor excludes lowercase map keys.
|
|
53
115
|
const LABEL_PATTERN = /:([A-Z][A-Za-z0-9_]*)/g;
|
|
54
116
|
|
|
55
|
-
|
|
117
|
+
// Exported so the post-write audit (graph-write/audit.ts) can reuse the same
|
|
118
|
+
// literal-stripping pass before its own static parsing — avoids duplicate
|
|
119
|
+
// false-positive treatment across the two surfaces.
|
|
120
|
+
export function stripStringLiterals(cypher: string): string {
|
|
56
121
|
// Replace single- and double-quoted literals with empty quotes. Preserves
|
|
57
122
|
// positional structure without retaining content that could match the
|
|
58
123
|
// label regex (e.g. ':SomeLabel' inside a string).
|
|
@@ -118,19 +183,40 @@ function hintFor(
|
|
|
118
183
|
return `${didYouMean}Unknown ${kind} '${token}' — not in the current Neo4j schema. See .docs/neo4j.md for the canonical taxonomy.`;
|
|
119
184
|
}
|
|
120
185
|
|
|
186
|
+
function detectForbidden(cypher: string): ForbiddenPattern[] {
|
|
187
|
+
// Strip literals so a property value like 'CREATE INDEX archive' cannot
|
|
188
|
+
// trip a false rejection. The shared `stripStringLiterals` keeps this
|
|
189
|
+
// treatment uniform with token extraction and audit static parsing.
|
|
190
|
+
const cleaned = stripStringLiterals(cypher);
|
|
191
|
+
const out: ForbiddenPattern[] = [];
|
|
192
|
+
for (const { kind, pattern, hint } of FORBIDDEN_PATTERNS) {
|
|
193
|
+
const found = cleaned.match(pattern);
|
|
194
|
+
if (found) {
|
|
195
|
+
out.push({ kind, match: found[0], hint });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
121
201
|
export function validate(
|
|
122
202
|
cypher: string,
|
|
123
203
|
snapshot: SchemaSnapshot,
|
|
204
|
+
options: ValidateOptions = {},
|
|
124
205
|
): ValidationResult {
|
|
206
|
+
const mode = options.mode ?? "read";
|
|
207
|
+
const forbidden = mode === "write" ? detectForbidden(cypher) : [];
|
|
125
208
|
const { labels, edges } = extractTokens(cypher);
|
|
126
209
|
// Fail-open when the snapshot is empty. An empty snapshot means "schema
|
|
127
210
|
// cache not loaded" (boot race, Neo4j unreachable). Rejecting every token
|
|
128
211
|
// would wedge the admin agent; letting it through preserves the observable
|
|
129
212
|
// `validated=false` signal on the existing [graph-query] line.
|
|
213
|
+
// Forbidden DDL/admin patterns still apply — those are syntactic, not
|
|
214
|
+
// schema-derived.
|
|
130
215
|
if (snapshot.labels.size === 0 && snapshot.relationshipTypes.size === 0) {
|
|
131
216
|
return {
|
|
132
|
-
ok:
|
|
217
|
+
ok: forbidden.length === 0,
|
|
133
218
|
unknown: [],
|
|
219
|
+
forbidden,
|
|
134
220
|
labelTokens: [...labels],
|
|
135
221
|
edgeTokens: [...edges],
|
|
136
222
|
};
|
|
@@ -148,9 +234,15 @@ export function validate(
|
|
|
148
234
|
unknown.push({ token, kind: "relationship", nearest, hint: hintFor(token, "relationship", nearest) });
|
|
149
235
|
}
|
|
150
236
|
}
|
|
237
|
+
// Write-mode caller treats `unknown` as a soft warning (audit), not a
|
|
238
|
+
// hard reject — operators legitimately introduce new labels via REMOVE
|
|
239
|
+
// n:Old SET n:New. Read-mode preserves prior behaviour: any unknown
|
|
240
|
+
// → ok=false.
|
|
241
|
+
const tokensOk = mode === "write" ? true : unknown.length === 0;
|
|
151
242
|
return {
|
|
152
|
-
ok:
|
|
243
|
+
ok: tokensOk && forbidden.length === 0,
|
|
153
244
|
unknown,
|
|
245
|
+
forbidden,
|
|
154
246
|
labelTokens: [...labels],
|
|
155
247
|
edgeTokens: [...edges],
|
|
156
248
|
};
|