@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
|
@@ -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 {
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
258
|
-
//
|
|
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
|
-
|
|
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=${
|
|
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=${
|
|
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 (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
`[
|
|
749
|
+
`[cypher-validate] tool=write outcome=warned unknown=${tokenSummary} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
|
|
422
750
|
);
|
|
423
|
-
return
|
|
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
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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
|
-
*
|
|
164
|
-
*
|
|
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
|
|
172
|
-
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"audit.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/audit.test.ts"],"names":[],"mappings":""}
|