@rubytech/create-maxy 1.0.743 → 1.0.744

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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate-write.test.d.ts +2 -0
  3. package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate-write.test.d.ts.map +1 -0
  4. package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate-write.test.js +97 -0
  5. package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate-write.test.js.map +1 -0
  6. package/payload/platform/lib/graph-mcp/dist/cypher-validate.d.ts +13 -1
  7. package/payload/platform/lib/graph-mcp/dist/cypher-validate.d.ts.map +1 -1
  8. package/payload/platform/lib/graph-mcp/dist/cypher-validate.js +70 -3
  9. package/payload/platform/lib/graph-mcp/dist/cypher-validate.js.map +1 -1
  10. package/payload/platform/lib/graph-mcp/dist/index.js +154 -11
  11. package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
  12. package/payload/platform/lib/graph-mcp/src/__tests__/cypher-validate-write.test.ts +150 -0
  13. package/payload/platform/lib/graph-mcp/src/cypher-validate.ts +95 -3
  14. package/payload/platform/lib/graph-mcp/src/index.ts +202 -17
  15. package/payload/platform/lib/graph-write/dist/__tests__/audit.test.d.ts +2 -0
  16. package/payload/platform/lib/graph-write/dist/__tests__/audit.test.d.ts.map +1 -0
  17. package/payload/platform/lib/graph-write/dist/__tests__/audit.test.js +147 -0
  18. package/payload/platform/lib/graph-write/dist/__tests__/audit.test.js.map +1 -0
  19. package/payload/platform/lib/graph-write/dist/audit.d.ts +84 -0
  20. package/payload/platform/lib/graph-write/dist/audit.d.ts.map +1 -0
  21. package/payload/platform/lib/graph-write/dist/audit.js +129 -0
  22. package/payload/platform/lib/graph-write/dist/audit.js.map +1 -0
  23. package/payload/platform/lib/graph-write/dist/index.d.ts +1 -0
  24. package/payload/platform/lib/graph-write/dist/index.d.ts.map +1 -1
  25. package/payload/platform/lib/graph-write/dist/index.js +18 -22
  26. package/payload/platform/lib/graph-write/dist/index.js.map +1 -1
  27. package/payload/platform/lib/graph-write/src/__tests__/audit.test.ts +162 -0
  28. package/payload/platform/lib/graph-write/src/audit.ts +182 -0
  29. package/payload/platform/lib/graph-write/src/index.ts +5 -0
  30. package/payload/platform/package.json +2 -2
  31. package/payload/platform/plugins/docs/references/memory-guide.md +2 -0
  32. package/payload/platform/plugins/docs/references/troubleshooting.md +16 -0
  33. package/payload/platform/templates/specialists/agents/database-operator.md +39 -6
  34. package/payload/server/chunk-2T4RRIJK.js +9462 -0
  35. package/payload/server/maxy-edge.js +94 -16
  36. package/payload/server/server.js +1 -1
@@ -0,0 +1,150 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { validate, type SchemaSnapshot } from "../cypher-validate.js";
4
+
5
+ const snapshot: SchemaSnapshot = {
6
+ labels: new Set(["Person", "Organization", "Task", "KnowledgeDocument"]),
7
+ relationshipTypes: new Set(["KNOWS", "PARTICIPANT", "REFERENCES", "MENTIONS", "PART_OF"]),
8
+ };
9
+
10
+ test("write mode allows CREATE node + relationship statement", () => {
11
+ const result = validate(
12
+ "MATCH (a:Person), (b:Organization) WHERE a.name = $a AND b.name = $b CREATE (a)-[:KNOWS]->(b)",
13
+ snapshot,
14
+ { mode: "write" },
15
+ );
16
+ assert.equal(result.ok, true, "expected valid write");
17
+ assert.deepEqual(result.unknown, []);
18
+ assert.deepEqual(result.forbidden, []);
19
+ });
20
+
21
+ test("write mode allows MERGE with SET clause", () => {
22
+ const result = validate(
23
+ "MATCH (a:Person {name: $name}) MERGE (a)-[r:KNOWS]->(b:Person {name: $other}) SET r.createdAt = datetime()",
24
+ snapshot,
25
+ { mode: "write" },
26
+ );
27
+ assert.equal(result.ok, true);
28
+ });
29
+
30
+ test("write mode allows DETACH DELETE", () => {
31
+ const result = validate(
32
+ "MATCH (n:Person) WHERE n.archived = true DETACH DELETE n",
33
+ snapshot,
34
+ { mode: "write" },
35
+ );
36
+ assert.equal(result.ok, true);
37
+ });
38
+
39
+ test("write mode allows REMOVE label + SET label (re-label)", () => {
40
+ // Allow renaming labels — the new label is unknown to the schema until the
41
+ // write commits, but the validator must not block legitimate normalisation.
42
+ const result = validate(
43
+ "MATCH (n:Person) WHERE n.legacy = true REMOVE n:Person SET n:LegacyPerson",
44
+ snapshot,
45
+ { mode: "write" },
46
+ );
47
+ // LegacyPerson is unknown — fail-soft for write mode (audit emits warning, not reject).
48
+ // The token check is shared with read-mode; the write mode adds DDL/admin reject layer.
49
+ // For this test we accept the write-mode-specific outcome: no forbidden patterns.
50
+ assert.deepEqual(result.forbidden, []);
51
+ });
52
+
53
+ test("write mode allows CALL apoc.refactor.mergeNodes", () => {
54
+ const result = validate(
55
+ "MATCH (a:Person), (b:Person) WHERE a.email = b.email AND id(a) < id(b) WITH a, b CALL apoc.refactor.mergeNodes([a, b]) YIELD node RETURN node",
56
+ snapshot,
57
+ { mode: "write" },
58
+ );
59
+ assert.deepEqual(result.forbidden, []);
60
+ });
61
+
62
+ test("write mode REJECTS DROP DATABASE", () => {
63
+ const result = validate("DROP DATABASE neo4j", snapshot, { mode: "write" });
64
+ assert.equal(result.ok, false);
65
+ assert.equal(result.forbidden.length, 1);
66
+ assert.equal(result.forbidden[0].kind, "drop-database");
67
+ });
68
+
69
+ test("write mode REJECTS CREATE INDEX", () => {
70
+ const result = validate(
71
+ "CREATE INDEX person_name_idx FOR (n:Person) ON (n.name)",
72
+ snapshot,
73
+ { mode: "write" },
74
+ );
75
+ assert.equal(result.ok, false);
76
+ const f = result.forbidden.find((x) => x.kind === "create-index");
77
+ assert.ok(f, "expected create-index forbidden");
78
+ });
79
+
80
+ test("write mode REJECTS CREATE CONSTRAINT", () => {
81
+ const result = validate(
82
+ "CREATE CONSTRAINT person_email_unique FOR (n:Person) REQUIRE n.email IS UNIQUE",
83
+ snapshot,
84
+ { mode: "write" },
85
+ );
86
+ assert.equal(result.ok, false);
87
+ const f = result.forbidden.find((x) => x.kind === "create-constraint");
88
+ assert.ok(f);
89
+ });
90
+
91
+ test("write mode REJECTS CALL dbms.security.*", () => {
92
+ const result = validate(
93
+ "CALL dbms.security.createUser('attacker', 'pwd', false)",
94
+ snapshot,
95
+ { mode: "write" },
96
+ );
97
+ assert.equal(result.ok, false);
98
+ const f = result.forbidden.find((x) => x.kind === "call-dbms");
99
+ assert.ok(f);
100
+ });
101
+
102
+ test("write mode REJECTS CALL db.create*", () => {
103
+ const result = validate(
104
+ "CALL db.createLabel('Foo')",
105
+ snapshot,
106
+ { mode: "write" },
107
+ );
108
+ assert.equal(result.ok, false);
109
+ const f = result.forbidden.find((x) => x.kind === "call-db-create");
110
+ assert.ok(f);
111
+ });
112
+
113
+ test("write mode allows CALL db.labels (read-only db.* call)", () => {
114
+ // db.labels is a read primitive — not a create/drop. Validator only
115
+ // forbids db.create* family; CALL db.labels remains allowed.
116
+ const result = validate("CALL db.labels()", snapshot, { mode: "write" });
117
+ assert.deepEqual(result.forbidden, []);
118
+ });
119
+
120
+ test("read mode does NOT apply forbidden checks (DDL pattern in MATCH still allowed)", () => {
121
+ // String literals containing forbidden tokens never trip — DDL forbidden check
122
+ // is mode-gated. Read-mode WHERE clauses comparing on a string with the
123
+ // word DROP must not reject.
124
+ const result = validate(
125
+ "MATCH (n:Person) WHERE n.bio CONTAINS 'DROP DATABASE' RETURN n",
126
+ snapshot,
127
+ { mode: "read" },
128
+ );
129
+ assert.deepEqual(result.forbidden, []);
130
+ assert.equal(result.ok, true);
131
+ });
132
+
133
+ test("write mode strips string literals before forbidden check", () => {
134
+ // The forbidden check must use stripStringLiterals so a string property
135
+ // value like 'DROP DATABASE archive' does not trip the rejection.
136
+ const result = validate(
137
+ "MATCH (n:Person) WHERE n.bio CONTAINS 'DROP DATABASE' SET n.flagged = true",
138
+ snapshot,
139
+ { mode: "write" },
140
+ );
141
+ assert.deepEqual(result.forbidden, [], "string-literal content must not trip DDL check");
142
+ });
143
+
144
+ test("default mode is read (backward compatibility)", () => {
145
+ // Existing call sites pass no mode option — must remain read-mode behavior.
146
+ const result = validate("MATCH (n:Person) RETURN n", snapshot);
147
+ assert.equal(result.ok, true);
148
+ assert.equal(Array.isArray(result.forbidden), true);
149
+ assert.equal(result.forbidden.length, 0);
150
+ });
@@ -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
- function stripStringLiterals(cypher: string): string {
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: true,
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: unknown.length === 0,
243
+ ok: tokensOk && forbidden.length === 0,
153
244
  unknown,
245
+ forbidden,
154
246
  labelTokens: [...labels],
155
247
  edgeTokens: [...edges],
156
248
  };
@@ -28,7 +28,16 @@ import { accessSync, appendFileSync, constants, mkdirSync, readFileSync, statSyn
28
28
  import { resolve } from "node:path";
29
29
  import { StringDecoder } from "node:string_decoder";
30
30
  import { initStderrTee } from "../../mcp-stderr-tee/dist/index.js";
31
- import { validate as validateCypher, type UnknownToken } from "./cypher-validate.js";
31
+ import {
32
+ validate as validateCypher,
33
+ type ForbiddenPattern,
34
+ type UnknownToken,
35
+ } from "./cypher-validate.js";
36
+ import {
37
+ auditCypherWrite,
38
+ formatAuditLine,
39
+ type AuditWarning,
40
+ } from "../../graph-write/dist/audit.js";
32
41
  import { SchemaCache, neo4jSchemaFetcher } from "./schema-cache.js";
33
42
 
34
43
  const SERVER_NAME = "graph";
@@ -162,7 +171,15 @@ const neo4jPassword = resolvePassword();
162
171
  const portMatch = /:(\d+)$/.exec(neo4jUri);
163
172
  const neo4jPort = portMatch ? portMatch[1] : "?";
164
173
  const namespace = process.env.NEO4J_NAMESPACE ?? "maxy-graph";
165
- const readOnly = process.env.NEO4J_READ_ONLY ?? "true";
174
+ // Task 796 default flipped from "true" to "false" so the upstream
175
+ // `mcp-neo4j-cypher` registers `write_neo4j_cypher` alongside the read tool.
176
+ // Per-agent gating via the `tools:` frontmatter list confines the surface:
177
+ // only `database-operator.md` lists `mcp__graph__maxy-graph-write_neo4j_cypher`,
178
+ // and the parent admin spawn only allow-lists it via `ADMIN_CORE_TOOLS` so
179
+ // the operator subagent inherits permission. Operators who set
180
+ // NEO4J_READ_ONLY=true explicitly (e.g. dev sandboxes) still get the
181
+ // upstream's read-only mode.
182
+ const readOnly = process.env.NEO4J_READ_ONLY ?? "false";
166
183
  const responseTokenLimit = process.env.NEO4J_RESPONSE_TOKEN_LIMIT ?? "20000";
167
184
 
168
185
  const childEnv: NodeJS.ProcessEnv = {
@@ -250,18 +267,31 @@ child.stderr.on("data", (chunk: Buffer) => {
250
267
  process.stderr.write(chunk);
251
268
  });
252
269
 
253
- // --- JSON-RPC call correlation + validation (Task 654) ---
270
+ // --- JSON-RPC call correlation + validation (Task 654, Task 796) ---
254
271
  // tools/call is the only method we time or validate. For read/write cypher
255
272
  // calls, the line is validated against the schema cache before forwarding.
256
273
  // Write-path rejection: synthesised MCP tool-error response on stdout, NOT
257
- // forwarded. Read-path rejection: forwarded, with warnings appendix prepended
258
- // to response.content[0].text.
274
+ // forwarded fires only on forbidden DDL/admin patterns (Task 796 reframe;
275
+ // unknown labels/types in writes are warnings, not rejections, since
276
+ // operators legitimately introduce new labels via REMOVE/SET). Read-path
277
+ // rejection: forwarded, with warnings appendix prepended to
278
+ // response.content[0].text.
259
279
  interface PendingCall {
260
280
  method: string;
261
281
  cypherPrefix: string | null;
282
+ /** Full cypher body — Task 796: needed for the post-write audit's static parse. */
283
+ cypherFull: string | null;
284
+ isWrite: boolean;
285
+ /** Caller-supplied sessionId param, used by the post-write audit emission. */
286
+ sessionIdParam: string | null;
262
287
  startMs: number;
263
288
  validated: boolean;
264
289
  readWarnings: UnknownToken[];
290
+ /** Task 796: unknown relationship tokens carried from request to response so
291
+ * the audit emits one `unknown-type-warning` line per unknown after the
292
+ * upstream commits (the validator only detected them; emission waits for
293
+ * the post-write line family). */
294
+ writeUnknownTokens: UnknownToken[];
265
295
  }
266
296
  const pending = new Map<string | number, PendingCall>();
267
297
 
@@ -296,6 +326,39 @@ function extractCypherFull(args: Record<string, unknown> | undefined): string |
296
326
  return typeof q === "string" ? q : null;
297
327
  }
298
328
 
329
+ // Task 796: the operator's Graph Stewardship Doctrine (Rule 3) requires
330
+ // `r.createdBySession = $sessionId` on every write. The post-write audit
331
+ // emits the `[graph-cypher-write] accepted` line with this sessionId so
332
+ // later forensic queries can join the audit log to the persisted node set
333
+ // via `WHERE n.createdBySession = $sessionId`. Returns null if the operator
334
+ // did not pass a sessionId param — the audit then emits sessionId=unknown
335
+ // and the missing-provenance warning fires from the static parse.
336
+ function extractSessionIdParam(args: Record<string, unknown> | undefined): string | null {
337
+ if (!args) return null;
338
+ const params = args["params"];
339
+ if (!params || typeof params !== "object") return null;
340
+ const sid = (params as Record<string, unknown>)["sessionId"];
341
+ return typeof sid === "string" ? sid : null;
342
+ }
343
+
344
+ // Task 796: parse the upstream `mcp-neo4j-cypher` write response counters.
345
+ // The upstream emits human-readable summary phrases ("3 nodes created", "4
346
+ // relationships created") in the result.content[0].text. Defensive regex —
347
+ // missing phrases coerce to 0 so the accepted line still emits with
348
+ // observable nodesCreated/relsCreated fields.
349
+ function parseWriteCounters(
350
+ result: JsonRpcMessage["result"],
351
+ ): { nodes: number; rels: number } {
352
+ if (!result?.content || !Array.isArray(result.content)) return { nodes: 0, rels: 0 };
353
+ const text = result.content[0]?.text ?? "";
354
+ const nodesMatch = text.match(/(\d+)\s*nodes?\s*created/i);
355
+ const relsMatch = text.match(/(\d+)\s*relationships?\s*created/i);
356
+ return {
357
+ nodes: nodesMatch ? parseInt(nodesMatch[1], 10) : 0,
358
+ rels: relsMatch ? parseInt(relsMatch[1], 10) : 0,
359
+ };
360
+ }
361
+
299
362
  function truncateForLog(cypher: string): string {
300
363
  return truncate(cypher.replace(/\s+/g, " ").trim(), 80);
301
364
  }
@@ -340,6 +403,33 @@ function synthesiseRejection(id: string | number, unknown: UnknownToken[]): stri
340
403
  return JSON.stringify(envelope);
341
404
  }
342
405
 
406
+ // Task 796: synthesised rejection for forbidden DDL/admin patterns. Fired
407
+ // from the write-mode validator when the operator (or a regression in admin
408
+ // dispatch) tries `DROP DATABASE`, `CREATE INDEX/CONSTRAINT`, `CALL dbms.*`,
409
+ // or `CALL db.create*`. The text names every matched pattern so the operator
410
+ // reads exactly which clause tripped the gate.
411
+ function synthesiseForbiddenRejection(
412
+ id: string | number,
413
+ forbidden: ForbiddenPattern[],
414
+ ): string {
415
+ const lines = forbidden.map(
416
+ (f) => ` - ${f.kind}: matched "${f.match.replace(/"/g, "'")}" — ${f.hint}`,
417
+ );
418
+ const text =
419
+ `forbidden cypher pattern — write NOT executed\n${lines.join("\n")}\n\n` +
420
+ `Index/constraint DDL is admin-owned (seed-neo4j.sh). Security CALL surface ` +
421
+ `(dbms.*, db.create*) is not exposed to the operator role.`;
422
+ const envelope = {
423
+ jsonrpc: "2.0",
424
+ id,
425
+ result: {
426
+ content: [{ type: "text", text }],
427
+ isError: true,
428
+ },
429
+ };
430
+ return JSON.stringify(envelope);
431
+ }
432
+
343
433
  function wrapReadWarnings(msg: JsonRpcMessage, warnings: UnknownToken[]): string {
344
434
  const warningText = `${renderUnknownTokens(warnings, "warning")}\n\n--- results below (executed despite unknown tokens) ---\n`;
345
435
  const original = msg.result?.content ?? [];
@@ -369,13 +459,21 @@ function handleRequestLine(line: string): RequestDecision {
369
459
  const cypherPrefix = cypherFull ? truncateForLog(cypherFull) : null;
370
460
  const isCypherCall =
371
461
  methodName === READ_CYPHER_TOOL || methodName === WRITE_CYPHER_TOOL;
462
+ const isWriteCall = methodName === WRITE_CYPHER_TOOL;
463
+ const sessionIdParam = isWriteCall
464
+ ? extractSessionIdParam(msg.params?.arguments)
465
+ : null;
372
466
 
373
467
  const entry: PendingCall = {
374
468
  method: methodName,
375
469
  cypherPrefix,
470
+ cypherFull,
471
+ isWrite: isWriteCall,
472
+ sessionIdParam,
376
473
  startMs: Date.now(),
377
474
  validated: false,
378
475
  readWarnings: [],
476
+ writeUnknownTokens: [],
379
477
  };
380
478
 
381
479
  if (!isCypherCall || !cypherFull) {
@@ -383,24 +481,40 @@ function handleRequestLine(line: string): RequestDecision {
383
481
  return "forward";
384
482
  }
385
483
 
386
- const isWrite = methodName === WRITE_CYPHER_TOOL;
387
484
  const snapshot = schemaCache.snapshot();
388
485
  const cacheReady = schemaCache.ready();
389
486
 
390
487
  if (!cacheReady) {
391
488
  console.error(
392
- `[cypher-validate] tool=${isWrite ? "write" : "read"} outcome=skipped reason=cache-not-ready cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
489
+ `[cypher-validate] tool=${isWriteCall ? "write" : "read"} outcome=skipped reason=cache-not-ready cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
393
490
  );
394
491
  pending.set(msg.id, entry);
395
492
  return "forward";
396
493
  }
397
494
 
398
- const result = validateCypher(cypherFull, snapshot);
495
+ const result = validateCypher(cypherFull, snapshot, {
496
+ mode: isWriteCall ? "write" : "read",
497
+ });
399
498
  entry.validated = true;
400
499
 
500
+ // Task 796: forbidden DDL/admin → hard reject in write mode (only path
501
+ // that produces forbidden entries; read mode never populates them).
502
+ if (isWriteCall && result.forbidden.length > 0) {
503
+ const forbiddenSummary = result.forbidden.map((f) => f.kind).join(",");
504
+ console.error(
505
+ `[cypher-validate] tool=write outcome=rejected forbidden=${forbiddenSummary} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
506
+ );
507
+ const response = synthesiseForbiddenRejection(msg.id, result.forbidden);
508
+ process.stdout.write(`${response}\n`);
509
+ console.error(
510
+ `[graph-query] op=${methodName} brand=${brand} port=${neo4jPort} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}" rejected=true forbidden=${forbiddenSummary} validated=true ms=${Date.now() - entry.startMs}`,
511
+ );
512
+ return "intercepted";
513
+ }
514
+
401
515
  if (result.ok) {
402
516
  console.error(
403
- `[cypher-validate] tool=${isWrite ? "write" : "read"} outcome=accepted labels=${result.labelTokens.length} relationships=${result.edgeTokens.length}`,
517
+ `[cypher-validate] tool=${isWriteCall ? "write" : "read"} outcome=accepted labels=${result.labelTokens.length} relationships=${result.edgeTokens.length}`,
404
518
  );
405
519
  pending.set(msg.id, entry);
406
520
  return "forward";
@@ -411,16 +525,18 @@ function handleRequestLine(line: string): RequestDecision {
411
525
  .map((u) => `${u.kind}:${u.token}`)
412
526
  .join(",");
413
527
 
414
- if (isWrite) {
415
- console.error(
416
- `[cypher-validate] tool=write outcome=rejected unknown=${tokenSummary} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
417
- );
418
- const response = synthesiseRejection(msg.id, result.unknown);
419
- process.stdout.write(`${response}\n`);
528
+ if (isWriteCall) {
529
+ // Task 796: write mode treats unknown tokens as soft warnings — operators
530
+ // legitimately introduce new labels (REMOVE n:Old SET n:New) and edges
531
+ // pending an ontology update. The post-write audit emits one
532
+ // unknown-type-warning per unknown so operators see the gap; the cypher
533
+ // forwards to upstream and commits.
534
+ entry.writeUnknownTokens = result.unknown;
420
535
  console.error(
421
- `[graph-query] op=${methodName} brand=${brand} port=${neo4jPort} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}" rejected=true validated=true ms=${Date.now() - entry.startMs}`,
536
+ `[cypher-validate] tool=write outcome=warned unknown=${tokenSummary} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
422
537
  );
423
- return "intercepted";
538
+ pending.set(msg.id, entry);
539
+ return "forward";
424
540
  }
425
541
 
426
542
  entry.readWarnings = result.unknown;
@@ -456,6 +572,75 @@ function handleResponseLine(line: string): string | null {
456
572
  console.error(
457
573
  `[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} rows=${rows} ${validatedField}${warnedField} ms=${elapsed}`,
458
574
  );
575
+
576
+ // Task 796 — post-write audit. The shim emits the `[graph-cypher-write]`
577
+ // family alongside the existing `[graph-query]` line so operators have a
578
+ // dedicated stream to grep without read-side noise. Static-only audit:
579
+ // unknown-type and missing-provenance warnings come from regex over the
580
+ // cypher body. Dynamic orphan detection (querying Neo4j for nodes
581
+ // matching createdBySession=$id AND NOT (n)--()) is deferred to Task 797
582
+ // — the audit module's `orphanIds` input stays empty here, the
583
+ // `[graph-cypher-write] orphan-warning` line never fires from production
584
+ // until that wires up.
585
+ if (p.isWrite && p.cypherFull && msg.result && !msg.result.isError) {
586
+ const counters = parseWriteCounters(msg.result);
587
+ const cypherPrefix = p.cypherPrefix ?? "";
588
+ const agentName = process.env.AGENT_SLUG ?? "unknown";
589
+ const sessionIdField = p.sessionIdParam ?? "unknown";
590
+ const snapshot = schemaCache.snapshot();
591
+ console.error(
592
+ formatAuditLine({
593
+ kind: "accepted",
594
+ cypherPrefix,
595
+ nodesCreated: counters.nodes,
596
+ relsCreated: counters.rels,
597
+ agentName,
598
+ sessionId: sessionIdField,
599
+ }),
600
+ );
601
+ const auditWarnings: AuditWarning[] = auditCypherWrite({
602
+ cypher: p.cypherFull,
603
+ schema: snapshot,
604
+ agentName,
605
+ sessionId: sessionIdField,
606
+ nodesCreated: counters.nodes,
607
+ relsCreated: counters.rels,
608
+ orphanIds: [],
609
+ });
610
+ for (const w of auditWarnings) {
611
+ switch (w.kind) {
612
+ case "unknown-type-warning":
613
+ console.error(
614
+ formatAuditLine({
615
+ kind: "unknown-type-warning",
616
+ cypherPrefix,
617
+ type: w.type,
618
+ }),
619
+ );
620
+ break;
621
+ case "missing-provenance-warning":
622
+ console.error(
623
+ formatAuditLine({
624
+ kind: "missing-provenance-warning",
625
+ cypherPrefix,
626
+ created: w.created,
627
+ stamped: w.stamped,
628
+ }),
629
+ );
630
+ break;
631
+ case "orphan-warning":
632
+ console.error(
633
+ formatAuditLine({
634
+ kind: "orphan-warning",
635
+ cypherPrefix,
636
+ orphanIds: w.orphanIds,
637
+ }),
638
+ );
639
+ break;
640
+ }
641
+ }
642
+ }
643
+
459
644
  if (p.readWarnings.length > 0) {
460
645
  try {
461
646
  return wrapReadWarnings(msg, p.readWarnings);
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=audit.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/audit.test.ts"],"names":[],"mappings":""}