@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.
Files changed (55) 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-rewrite-stamp.d.ts +37 -0
  7. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.d.ts.map +1 -0
  8. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.js +333 -0
  9. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.js.map +1 -0
  10. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.d.ts +71 -0
  11. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.d.ts.map +1 -0
  12. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.js +168 -0
  13. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.js.map +1 -0
  14. package/payload/platform/lib/graph-mcp/dist/cypher-validate.d.ts +13 -1
  15. package/payload/platform/lib/graph-mcp/dist/cypher-validate.d.ts.map +1 -1
  16. package/payload/platform/lib/graph-mcp/dist/cypher-validate.js +70 -3
  17. package/payload/platform/lib/graph-mcp/dist/cypher-validate.js.map +1 -1
  18. package/payload/platform/lib/graph-mcp/dist/index.js +297 -11
  19. package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
  20. package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts +3 -6
  21. package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts.map +1 -1
  22. package/payload/platform/lib/graph-mcp/dist/schema-cache.js +30 -7
  23. package/payload/platform/lib/graph-mcp/dist/schema-cache.js.map +1 -1
  24. package/payload/platform/lib/graph-mcp/src/__tests__/cypher-validate-write.test.ts +150 -0
  25. package/payload/platform/lib/graph-mcp/src/cypher-rewrite-stamp.ts +349 -0
  26. package/payload/platform/lib/graph-mcp/src/cypher-shim-write.ts +240 -0
  27. package/payload/platform/lib/graph-mcp/src/cypher-validate.ts +95 -3
  28. package/payload/platform/lib/graph-mcp/src/index.ts +415 -18
  29. package/payload/platform/lib/graph-mcp/src/schema-cache.ts +37 -7
  30. package/payload/platform/lib/graph-write/dist/__tests__/audit.test.d.ts +2 -0
  31. package/payload/platform/lib/graph-write/dist/__tests__/audit.test.d.ts.map +1 -0
  32. package/payload/platform/lib/graph-write/dist/__tests__/audit.test.js +147 -0
  33. package/payload/platform/lib/graph-write/dist/__tests__/audit.test.js.map +1 -0
  34. package/payload/platform/lib/graph-write/dist/audit.d.ts +84 -0
  35. package/payload/platform/lib/graph-write/dist/audit.d.ts.map +1 -0
  36. package/payload/platform/lib/graph-write/dist/audit.js +129 -0
  37. package/payload/platform/lib/graph-write/dist/audit.js.map +1 -0
  38. package/payload/platform/lib/graph-write/dist/index.d.ts +1 -0
  39. package/payload/platform/lib/graph-write/dist/index.d.ts.map +1 -1
  40. package/payload/platform/lib/graph-write/dist/index.js +18 -22
  41. package/payload/platform/lib/graph-write/dist/index.js.map +1 -1
  42. package/payload/platform/lib/graph-write/src/__tests__/audit.test.ts +162 -0
  43. package/payload/platform/lib/graph-write/src/audit.ts +182 -0
  44. package/payload/platform/lib/graph-write/src/index.ts +5 -0
  45. package/payload/platform/package.json +2 -2
  46. package/payload/platform/plugins/docs/references/deployment.md +2 -1
  47. package/payload/platform/plugins/docs/references/memory-guide.md +2 -0
  48. package/payload/platform/plugins/docs/references/troubleshooting.md +16 -0
  49. package/payload/platform/templates/specialists/agents/database-operator.md +20 -6
  50. package/payload/server/chunk-2T4RRIJK.js +9462 -0
  51. package/payload/server/chunk-SPTD7L7Z.js +9474 -0
  52. package/payload/server/maxy-edge.js +94 -16
  53. package/payload/server/public/assets/{graph-DhNy70eS.js → graph-BoSJpLG3.js} +1 -1
  54. package/payload/server/public/graph.html +1 -1
  55. package/payload/server/server.js +1 -1
@@ -28,8 +28,26 @@ 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";
32
- import { SchemaCache, neo4jSchemaFetcher } from "./schema-cache.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";
41
+ import { SchemaCache, getSharedDriver, neo4jSchemaFetcher } from "./schema-cache.js";
42
+ import { rewriteWithProvenanceStamps } from "./cypher-rewrite-stamp.js";
43
+ import {
44
+ OrphanRollbackError,
45
+ runWriteTxBody,
46
+ synthesiseOrphanRollback,
47
+ synthesiseShimError,
48
+ synthesiseWriteResponse,
49
+ type GraphDriver,
50
+ } from "./cypher-shim-write.js";
33
51
 
34
52
  const SERVER_NAME = "graph";
35
53
  const UPSTREAM_PACKAGE = "mcp-neo4j-cypher@0.6.0";
@@ -162,7 +180,15 @@ const neo4jPassword = resolvePassword();
162
180
  const portMatch = /:(\d+)$/.exec(neo4jUri);
163
181
  const neo4jPort = portMatch ? portMatch[1] : "?";
164
182
  const namespace = process.env.NEO4J_NAMESPACE ?? "maxy-graph";
165
- const readOnly = process.env.NEO4J_READ_ONLY ?? "true";
183
+ // Task 796 default flipped from "true" to "false" so the upstream
184
+ // `mcp-neo4j-cypher` registers `write_neo4j_cypher` alongside the read tool.
185
+ // Per-agent gating via the `tools:` frontmatter list confines the surface:
186
+ // only `database-operator.md` lists `mcp__graph__maxy-graph-write_neo4j_cypher`,
187
+ // and the parent admin spawn only allow-lists it via `ADMIN_CORE_TOOLS` so
188
+ // the operator subagent inherits permission. Operators who set
189
+ // NEO4J_READ_ONLY=true explicitly (e.g. dev sandboxes) still get the
190
+ // upstream's read-only mode.
191
+ const readOnly = process.env.NEO4J_READ_ONLY ?? "false";
166
192
  const responseTokenLimit = process.env.NEO4J_RESPONSE_TOKEN_LIMIT ?? "20000";
167
193
 
168
194
  const childEnv: NodeJS.ProcessEnv = {
@@ -250,18 +276,31 @@ child.stderr.on("data", (chunk: Buffer) => {
250
276
  process.stderr.write(chunk);
251
277
  });
252
278
 
253
- // --- JSON-RPC call correlation + validation (Task 654) ---
279
+ // --- JSON-RPC call correlation + validation (Task 654, Task 796) ---
254
280
  // tools/call is the only method we time or validate. For read/write cypher
255
281
  // calls, the line is validated against the schema cache before forwarding.
256
282
  // 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.
283
+ // forwarded fires only on forbidden DDL/admin patterns (Task 796 reframe;
284
+ // unknown labels/types in writes are warnings, not rejections, since
285
+ // operators legitimately introduce new labels via REMOVE/SET). Read-path
286
+ // rejection: forwarded, with warnings appendix prepended to
287
+ // response.content[0].text.
259
288
  interface PendingCall {
260
289
  method: string;
261
290
  cypherPrefix: string | null;
291
+ /** Full cypher body — Task 796: needed for the post-write audit's static parse. */
292
+ cypherFull: string | null;
293
+ isWrite: boolean;
294
+ /** Caller-supplied sessionId param, used by the post-write audit emission. */
295
+ sessionIdParam: string | null;
262
296
  startMs: number;
263
297
  validated: boolean;
264
298
  readWarnings: UnknownToken[];
299
+ /** Task 796: unknown relationship tokens carried from request to response so
300
+ * the audit emits one `unknown-type-warning` line per unknown after the
301
+ * upstream commits (the validator only detected them; emission waits for
302
+ * the post-write line family). */
303
+ writeUnknownTokens: UnknownToken[];
265
304
  }
266
305
  const pending = new Map<string | number, PendingCall>();
267
306
 
@@ -296,6 +335,39 @@ function extractCypherFull(args: Record<string, unknown> | undefined): string |
296
335
  return typeof q === "string" ? q : null;
297
336
  }
298
337
 
338
+ // Task 796: the operator's Graph Stewardship Doctrine (Rule 3) requires
339
+ // `r.createdBySession = $sessionId` on every write. The post-write audit
340
+ // emits the `[graph-cypher-write] accepted` line with this sessionId so
341
+ // later forensic queries can join the audit log to the persisted node set
342
+ // via `WHERE n.createdBySession = $sessionId`. Returns null if the operator
343
+ // did not pass a sessionId param — the audit then emits sessionId=unknown
344
+ // and the missing-provenance warning fires from the static parse.
345
+ function extractSessionIdParam(args: Record<string, unknown> | undefined): string | null {
346
+ if (!args) return null;
347
+ const params = args["params"];
348
+ if (!params || typeof params !== "object") return null;
349
+ const sid = (params as Record<string, unknown>)["sessionId"];
350
+ return typeof sid === "string" ? sid : null;
351
+ }
352
+
353
+ // Task 796: parse the upstream `mcp-neo4j-cypher` write response counters.
354
+ // The upstream emits human-readable summary phrases ("3 nodes created", "4
355
+ // relationships created") in the result.content[0].text. Defensive regex —
356
+ // missing phrases coerce to 0 so the accepted line still emits with
357
+ // observable nodesCreated/relsCreated fields.
358
+ function parseWriteCounters(
359
+ result: JsonRpcMessage["result"],
360
+ ): { nodes: number; rels: number } {
361
+ if (!result?.content || !Array.isArray(result.content)) return { nodes: 0, rels: 0 };
362
+ const text = result.content[0]?.text ?? "";
363
+ const nodesMatch = text.match(/(\d+)\s*nodes?\s*created/i);
364
+ const relsMatch = text.match(/(\d+)\s*relationships?\s*created/i);
365
+ return {
366
+ nodes: nodesMatch ? parseInt(nodesMatch[1], 10) : 0,
367
+ rels: relsMatch ? parseInt(relsMatch[1], 10) : 0,
368
+ };
369
+ }
370
+
299
371
  function truncateForLog(cypher: string): string {
300
372
  return truncate(cypher.replace(/\s+/g, " ").trim(), 80);
301
373
  }
@@ -340,6 +412,33 @@ function synthesiseRejection(id: string | number, unknown: UnknownToken[]): stri
340
412
  return JSON.stringify(envelope);
341
413
  }
342
414
 
415
+ // Task 796: synthesised rejection for forbidden DDL/admin patterns. Fired
416
+ // from the write-mode validator when the operator (or a regression in admin
417
+ // dispatch) tries `DROP DATABASE`, `CREATE INDEX/CONSTRAINT`, `CALL dbms.*`,
418
+ // or `CALL db.create*`. The text names every matched pattern so the operator
419
+ // reads exactly which clause tripped the gate.
420
+ function synthesiseForbiddenRejection(
421
+ id: string | number,
422
+ forbidden: ForbiddenPattern[],
423
+ ): string {
424
+ const lines = forbidden.map(
425
+ (f) => ` - ${f.kind}: matched "${f.match.replace(/"/g, "'")}" — ${f.hint}`,
426
+ );
427
+ const text =
428
+ `forbidden cypher pattern — write NOT executed\n${lines.join("\n")}\n\n` +
429
+ `Index/constraint DDL is admin-owned (seed-neo4j.sh). Security CALL surface ` +
430
+ `(dbms.*, db.create*) is not exposed to the operator role.`;
431
+ const envelope = {
432
+ jsonrpc: "2.0",
433
+ id,
434
+ result: {
435
+ content: [{ type: "text", text }],
436
+ isError: true,
437
+ },
438
+ };
439
+ return JSON.stringify(envelope);
440
+ }
441
+
343
442
  function wrapReadWarnings(msg: JsonRpcMessage, warnings: UnknownToken[]): string {
344
443
  const warningText = `${renderUnknownTokens(warnings, "warning")}\n\n--- results below (executed despite unknown tokens) ---\n`;
345
444
  const original = msg.result?.content ?? [];
@@ -353,6 +452,174 @@ function wrapReadWarnings(msg: JsonRpcMessage, warnings: UnknownToken[]): string
353
452
  return JSON.stringify(wrapped);
354
453
  }
355
454
 
455
+ // --- Task 797: shim-owned write path -----------------------------------
456
+ // write_neo4j_cypher is intercepted (not forwarded to upstream). The shim
457
+ // runs the operator's cypher inside its own driver session under
458
+ // `executeWrite`, so provenance auto-stamping and orphan-rollback are
459
+ // transactionally atomic. Reads still pass through to upstream unchanged.
460
+ // Pure helpers (runWriteTxBody, synthesise*, OrphanRollbackError) live in
461
+ // cypher-shim-write.ts so they can be unit-tested without booting this
462
+ // module's stdin/stdout pipe and uvx spawn.
463
+
464
+ async function runShimWriteCypher(
465
+ id: string | number,
466
+ cypherFull: string,
467
+ cypherPrefix: string,
468
+ sessionIdParam: string | null,
469
+ operatorArgs: Record<string, unknown> | undefined,
470
+ startMs: number,
471
+ writeUnknownTokens: UnknownToken[],
472
+ validated: boolean,
473
+ ): Promise<void> {
474
+ const agentName = process.env.AGENT_SLUG ?? "unknown";
475
+ const sessionIdField =
476
+ sessionIdParam ?? process.env.SESSION_ID ?? "unknown";
477
+ const operatorParams =
478
+ (operatorArgs?.["params"] as Record<string, unknown> | undefined) ?? {};
479
+ const safePrefix = cypherPrefix.replace(/"/g, "'");
480
+
481
+ let rewrite: ReturnType<typeof rewriteWithProvenanceStamps>;
482
+ try {
483
+ rewrite = rewriteWithProvenanceStamps(cypherFull);
484
+ } catch (err) {
485
+ // Defensive — the rewriter is regex-only with no catastrophic-backtrack
486
+ // patterns, but a thrown error before driver acquisition would otherwise
487
+ // emit no [graph-query] line at all (review F3).
488
+ const errMsg = err instanceof Error ? err.message : String(err);
489
+ console.error(
490
+ `[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" error="rewrite-failed: ${errMsg.replace(/"/g, "'")}" validated=${validated} ms=${Date.now() - startMs}`,
491
+ );
492
+ process.stdout.write(
493
+ `${synthesiseShimError(id, "cypher rewrite failed — write NOT executed", errMsg)}\n`,
494
+ );
495
+ return;
496
+ }
497
+ if (rewrite.stampsAppended > 0) {
498
+ console.error(
499
+ `[graph-cypher-write] auto-stamp applied query="${safePrefix}" creates=${rewrite.stampsAppended}`,
500
+ );
501
+ }
502
+
503
+ let driver: GraphDriver;
504
+ try {
505
+ driver = (await getSharedDriver(resolvedNeo4jUri, neo4jUser, neo4jPassword)) as GraphDriver;
506
+ } catch (err) {
507
+ const errMsg = err instanceof Error ? err.message : String(err);
508
+ console.error(
509
+ `[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" error="driver-unavailable: ${errMsg.replace(/"/g, "'")}" validated=${validated} ms=${Date.now() - startMs}`,
510
+ );
511
+ process.stdout.write(
512
+ `${synthesiseShimError(id, "graph driver unavailable — write NOT executed", errMsg)}\n`,
513
+ );
514
+ return;
515
+ }
516
+
517
+ const session = driver.session();
518
+ let outcome: { nodesCreated: number; relsCreated: number; propertiesSet: number; serializedRecords: Array<Record<string, unknown>> } | null = null;
519
+
520
+ try {
521
+ outcome = await session.executeWrite((tx) =>
522
+ runWriteTxBody(tx, rewrite.cypher, operatorParams, {
523
+ agent: agentName,
524
+ session: sessionIdField,
525
+ }),
526
+ );
527
+ } catch (err) {
528
+ if (err instanceof OrphanRollbackError) {
529
+ console.error(
530
+ `[graph-cypher-write] orphan-rollback query="${safePrefix}" orphanLabels=${err.sampleLabels.join(",")}`,
531
+ );
532
+ console.error(
533
+ formatAuditLine({
534
+ kind: "orphan-warning",
535
+ cypherPrefix,
536
+ orphanIds: err.orphanIds,
537
+ }),
538
+ );
539
+ console.error(
540
+ `[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" rolledBack=true orphans=${err.orphanIds.length} validated=${validated} ms=${Date.now() - startMs}`,
541
+ );
542
+ process.stdout.write(
543
+ `${synthesiseOrphanRollback(id, err.orphanIds.length, err.sampleLabels)}\n`,
544
+ );
545
+ } else {
546
+ const errMsg = err instanceof Error ? err.message : String(err);
547
+ console.error(
548
+ `[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" error="${errMsg.replace(/"/g, "'")}" validated=${validated} ms=${Date.now() - startMs}`,
549
+ );
550
+ process.stdout.write(
551
+ `${synthesiseShimError(id, "Neo4j error", errMsg)}\n`,
552
+ );
553
+ }
554
+ return;
555
+ } finally {
556
+ await session.close().catch(() => {
557
+ /* session already closed by the driver on commit/rollback */
558
+ });
559
+ }
560
+
561
+ // outcome is non-null on the success path (the catch above returns early
562
+ // on rollback / shim error). Defensive narrow.
563
+ if (!outcome) return;
564
+
565
+ console.error(
566
+ `[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" rows=${outcome.serializedRecords.length} validated=${validated} ms=${Date.now() - startMs}`,
567
+ );
568
+ console.error(
569
+ formatAuditLine({
570
+ kind: "accepted",
571
+ cypherPrefix,
572
+ nodesCreated: outcome.nodesCreated,
573
+ relsCreated: outcome.relsCreated,
574
+ agentName,
575
+ sessionId: sessionIdField,
576
+ }),
577
+ );
578
+
579
+ for (const t of writeUnknownTokens.filter((u) => u.kind === "relationship")) {
580
+ console.error(
581
+ formatAuditLine({
582
+ kind: "unknown-type-warning",
583
+ cypherPrefix,
584
+ type: t.token,
585
+ }),
586
+ );
587
+ }
588
+
589
+ const auditWarnings: AuditWarning[] = auditCypherWrite({
590
+ cypher: rewrite.cypher,
591
+ schema: schemaCache.snapshot(),
592
+ agentName,
593
+ sessionId: sessionIdField,
594
+ nodesCreated: outcome.nodesCreated,
595
+ relsCreated: outcome.relsCreated,
596
+ orphanIds: [],
597
+ });
598
+ for (const w of auditWarnings) {
599
+ if (w.kind === "missing-provenance-warning") {
600
+ console.error(
601
+ formatAuditLine({
602
+ kind: "missing-provenance-warning",
603
+ cypherPrefix,
604
+ created: w.created,
605
+ stamped: w.stamped,
606
+ }),
607
+ );
608
+ }
609
+ // unknown-type already emitted from the validator's writeUnknownTokens above;
610
+ // orphan-warning fires only on the rollback path.
611
+ }
612
+
613
+ process.stdout.write(
614
+ `${synthesiseWriteResponse(id, {
615
+ nodesCreated: outcome.nodesCreated,
616
+ relsCreated: outcome.relsCreated,
617
+ propertiesSet: outcome.propertiesSet,
618
+ records: outcome.serializedRecords,
619
+ })}\n`,
620
+ );
621
+ }
622
+
356
623
  type RequestDecision = "forward" | "intercepted";
357
624
 
358
625
  function handleRequestLine(line: string): RequestDecision {
@@ -369,13 +636,21 @@ function handleRequestLine(line: string): RequestDecision {
369
636
  const cypherPrefix = cypherFull ? truncateForLog(cypherFull) : null;
370
637
  const isCypherCall =
371
638
  methodName === READ_CYPHER_TOOL || methodName === WRITE_CYPHER_TOOL;
639
+ const isWriteCall = methodName === WRITE_CYPHER_TOOL;
640
+ const sessionIdParam = isWriteCall
641
+ ? extractSessionIdParam(msg.params?.arguments)
642
+ : null;
372
643
 
373
644
  const entry: PendingCall = {
374
645
  method: methodName,
375
646
  cypherPrefix,
647
+ cypherFull,
648
+ isWrite: isWriteCall,
649
+ sessionIdParam,
376
650
  startMs: Date.now(),
377
651
  validated: false,
378
652
  readWarnings: [],
653
+ writeUnknownTokens: [],
379
654
  };
380
655
 
381
656
  if (!isCypherCall || !cypherFull) {
@@ -383,25 +658,77 @@ function handleRequestLine(line: string): RequestDecision {
383
658
  return "forward";
384
659
  }
385
660
 
386
- const isWrite = methodName === WRITE_CYPHER_TOOL;
661
+ // Task 797: writes are intercepted into the shim-owned driver path.
662
+ // Helper so each branch below routes consistently.
663
+ const interceptWrite = (): RequestDecision => {
664
+ void runShimWriteCypher(
665
+ msg.id as string | number,
666
+ cypherFull,
667
+ cypherPrefix ?? "",
668
+ sessionIdParam,
669
+ msg.params?.arguments,
670
+ entry.startMs,
671
+ entry.writeUnknownTokens,
672
+ entry.validated,
673
+ ).catch((err) => {
674
+ // Defensive catch — runShimWriteCypher already handles its own errors,
675
+ // but never let an unexpected throw leak past this point.
676
+ const errMsg = err instanceof Error ? err.message : String(err);
677
+ console.error(
678
+ `[graph-mcp] runShimWriteCypher unhandled error: ${errMsg.replace(/"/g, "'")}`,
679
+ );
680
+ try {
681
+ process.stdout.write(
682
+ `${synthesiseShimError(msg.id as string | number, "shim-internal error", errMsg)}\n`,
683
+ );
684
+ } catch {
685
+ /* stdout closed */
686
+ }
687
+ });
688
+ return "intercepted";
689
+ };
690
+
387
691
  const snapshot = schemaCache.snapshot();
388
692
  const cacheReady = schemaCache.ready();
389
693
 
390
694
  if (!cacheReady) {
391
695
  console.error(
392
- `[cypher-validate] tool=${isWrite ? "write" : "read"} outcome=skipped reason=cache-not-ready cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
696
+ `[cypher-validate] tool=${isWriteCall ? "write" : "read"} outcome=skipped reason=cache-not-ready cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
393
697
  );
698
+ if (isWriteCall) {
699
+ // Task 797: still rewrite + run inside shim tx even when validator
700
+ // is cold. Auto-stamp + orphan rollback don't depend on schema.
701
+ return interceptWrite();
702
+ }
394
703
  pending.set(msg.id, entry);
395
704
  return "forward";
396
705
  }
397
706
 
398
- const result = validateCypher(cypherFull, snapshot);
707
+ const result = validateCypher(cypherFull, snapshot, {
708
+ mode: isWriteCall ? "write" : "read",
709
+ });
399
710
  entry.validated = true;
400
711
 
712
+ // Task 796: forbidden DDL/admin → hard reject in write mode (only path
713
+ // that produces forbidden entries; read mode never populates them).
714
+ if (isWriteCall && result.forbidden.length > 0) {
715
+ const forbiddenSummary = result.forbidden.map((f) => f.kind).join(",");
716
+ console.error(
717
+ `[cypher-validate] tool=write outcome=rejected forbidden=${forbiddenSummary} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
718
+ );
719
+ const response = synthesiseForbiddenRejection(msg.id, result.forbidden);
720
+ process.stdout.write(`${response}\n`);
721
+ console.error(
722
+ `[graph-query] op=${methodName} brand=${brand} port=${neo4jPort} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}" rejected=true forbidden=${forbiddenSummary} validated=true ms=${Date.now() - entry.startMs}`,
723
+ );
724
+ return "intercepted";
725
+ }
726
+
401
727
  if (result.ok) {
402
728
  console.error(
403
- `[cypher-validate] tool=${isWrite ? "write" : "read"} outcome=accepted labels=${result.labelTokens.length} relationships=${result.edgeTokens.length}`,
729
+ `[cypher-validate] tool=${isWriteCall ? "write" : "read"} outcome=accepted labels=${result.labelTokens.length} relationships=${result.edgeTokens.length}`,
404
730
  );
731
+ if (isWriteCall) return interceptWrite();
405
732
  pending.set(msg.id, entry);
406
733
  return "forward";
407
734
  }
@@ -411,16 +738,17 @@ function handleRequestLine(line: string): RequestDecision {
411
738
  .map((u) => `${u.kind}:${u.token}`)
412
739
  .join(",");
413
740
 
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`);
741
+ if (isWriteCall) {
742
+ // Task 796: write mode treats unknown tokens as soft warnings — operators
743
+ // legitimately introduce new labels (REMOVE n:Old SET n:New) and edges
744
+ // pending an ontology update. The post-write audit emits one
745
+ // unknown-type-warning per unknown so operators see the gap; the cypher
746
+ // still commits via the shim-owned write path (Task 797).
747
+ entry.writeUnknownTokens = result.unknown;
420
748
  console.error(
421
- `[graph-query] op=${methodName} brand=${brand} port=${neo4jPort} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}" rejected=true validated=true ms=${Date.now() - entry.startMs}`,
749
+ `[cypher-validate] tool=write outcome=warned unknown=${tokenSummary} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
422
750
  );
423
- return "intercepted";
751
+ return interceptWrite();
424
752
  }
425
753
 
426
754
  entry.readWarnings = result.unknown;
@@ -456,6 +784,75 @@ function handleResponseLine(line: string): string | null {
456
784
  console.error(
457
785
  `[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} rows=${rows} ${validatedField}${warnedField} ms=${elapsed}`,
458
786
  );
787
+
788
+ // Task 796 — post-write audit. The shim emits the `[graph-cypher-write]`
789
+ // family alongside the existing `[graph-query]` line so operators have a
790
+ // dedicated stream to grep without read-side noise. Static-only audit:
791
+ // unknown-type and missing-provenance warnings come from regex over the
792
+ // cypher body. Dynamic orphan detection (querying Neo4j for nodes
793
+ // matching createdBySession=$id AND NOT (n)--()) is deferred to Task 797
794
+ // — the audit module's `orphanIds` input stays empty here, the
795
+ // `[graph-cypher-write] orphan-warning` line never fires from production
796
+ // until that wires up.
797
+ if (p.isWrite && p.cypherFull && msg.result && !msg.result.isError) {
798
+ const counters = parseWriteCounters(msg.result);
799
+ const cypherPrefix = p.cypherPrefix ?? "";
800
+ const agentName = process.env.AGENT_SLUG ?? "unknown";
801
+ const sessionIdField = p.sessionIdParam ?? "unknown";
802
+ const snapshot = schemaCache.snapshot();
803
+ console.error(
804
+ formatAuditLine({
805
+ kind: "accepted",
806
+ cypherPrefix,
807
+ nodesCreated: counters.nodes,
808
+ relsCreated: counters.rels,
809
+ agentName,
810
+ sessionId: sessionIdField,
811
+ }),
812
+ );
813
+ const auditWarnings: AuditWarning[] = auditCypherWrite({
814
+ cypher: p.cypherFull,
815
+ schema: snapshot,
816
+ agentName,
817
+ sessionId: sessionIdField,
818
+ nodesCreated: counters.nodes,
819
+ relsCreated: counters.rels,
820
+ orphanIds: [],
821
+ });
822
+ for (const w of auditWarnings) {
823
+ switch (w.kind) {
824
+ case "unknown-type-warning":
825
+ console.error(
826
+ formatAuditLine({
827
+ kind: "unknown-type-warning",
828
+ cypherPrefix,
829
+ type: w.type,
830
+ }),
831
+ );
832
+ break;
833
+ case "missing-provenance-warning":
834
+ console.error(
835
+ formatAuditLine({
836
+ kind: "missing-provenance-warning",
837
+ cypherPrefix,
838
+ created: w.created,
839
+ stamped: w.stamped,
840
+ }),
841
+ );
842
+ break;
843
+ case "orphan-warning":
844
+ console.error(
845
+ formatAuditLine({
846
+ kind: "orphan-warning",
847
+ cypherPrefix,
848
+ orphanIds: w.orphanIds,
849
+ }),
850
+ );
851
+ break;
852
+ }
853
+ }
854
+ }
855
+
459
856
  if (p.readWarnings.length > 0) {
460
857
  try {
461
858
  return wrapReadWarnings(msg, p.readWarnings);
@@ -156,20 +156,50 @@ export class SchemaCache {
156
156
  }
157
157
 
158
158
  /**
159
- * Build a SchemaFetcher over a long-lived neo4j-driver Driver. Each call
160
- * opens a fresh session (cheap, sub-ms) and closes it in finally so the
161
- * driver's connection pool stays small. Consumer owns the driver lifecycle.
159
+ * Module-scope driver singleton (Task 797). Both the schema-cache and the
160
+ * write-path (graph-mcp/src/index.ts `runShimWriteCypher`) borrow sessions
161
+ * from the same `neo4j-driver` Driver instance one connection pool per
162
+ * process. The first call binds the URI/credentials; subsequent calls
163
+ * return the same driver regardless of arguments. The driver lives for
164
+ * the process lifetime; closing it is the process exit's responsibility.
162
165
  *
163
- * Import is deferred to the factory call so that test files can exercise
164
- * the SchemaCache class without pulling neo4j-driver into the module graph.
166
+ * The import is deferred so test files can exercise the SchemaCache class
167
+ * without pulling neo4j-driver into their module graph.
168
+ */
169
+ let sharedDriverPromise: Promise<unknown> | null = null;
170
+
171
+ export async function getSharedDriver(
172
+ uri: string,
173
+ user: string,
174
+ password: string,
175
+ ): Promise<unknown> {
176
+ if (!sharedDriverPromise) {
177
+ // Clear the cached promise on rejection so a transient failure (e.g. a
178
+ // slow-to-import neo4j-driver during boot) doesn't wedge every subsequent
179
+ // call for the process lifetime (review F4).
180
+ sharedDriverPromise = (async () => {
181
+ const neo4j = await import("neo4j-driver");
182
+ return neo4j.default.driver(uri, neo4j.default.auth.basic(user, password));
183
+ })().catch((err) => {
184
+ sharedDriverPromise = null;
185
+ throw err;
186
+ });
187
+ }
188
+ return sharedDriverPromise;
189
+ }
190
+
191
+ /**
192
+ * Build a SchemaFetcher over the shared neo4j-driver Driver. Each call
193
+ * opens a fresh session (cheap, sub-ms) and closes it in finally.
165
194
  */
166
195
  export async function neo4jSchemaFetcher(
167
196
  uri: string,
168
197
  user: string,
169
198
  password: string,
170
199
  ): Promise<SchemaFetcher> {
171
- const neo4j = await import("neo4j-driver");
172
- const driver = neo4j.default.driver(uri, neo4j.default.auth.basic(user, password));
200
+ const driver = (await getSharedDriver(uri, user, password)) as {
201
+ session: () => { run: (q: string) => Promise<{ records: Array<{ get: (k: number) => unknown }> }>; close: () => Promise<void> };
202
+ };
173
203
  const query = async (cypher: string): Promise<string[]> => {
174
204
  const session = driver.session();
175
205
  try {
@@ -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":""}