@rubytech/create-maxy 1.0.742 → 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.
- 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-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 +154 -11
- package/payload/platform/lib/graph-mcp/dist/index.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-validate.ts +95 -3
- package/payload/platform/lib/graph-mcp/src/index.ts +202 -17
- 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/memory-guide.md +2 -0
- package/payload/platform/plugins/docs/references/troubleshooting.md +16 -0
- package/payload/platform/plugins/scheduling/mcp/dist/index.js +11 -4
- package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js +119 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts +22 -3
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js +33 -7
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/package.json +4 -2
- package/payload/platform/plugins/scheduling/mcp/vitest.config.ts +9 -0
- package/payload/platform/templates/specialists/agents/database-operator.md +39 -6
- package/payload/server/chunk-2T4RRIJK.js +9462 -0
- package/payload/server/chunk-JLVVVQN7.js +9447 -0
- package/payload/server/chunk-TXPEEAV6.js +2997 -0
- package/payload/server/client-pool-TCKGDZLE.js +28 -0
- package/payload/server/maxy-edge.js +95 -17
- package/payload/server/server.js +3 -3
|
@@ -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
|
-
|
|
117
|
+
// Exported so the post-write audit (graph-write/audit.ts) can reuse the same
|
|
118
|
+
// literal-stripping pass before its own static parsing — avoids duplicate
|
|
119
|
+
// false-positive treatment across the two surfaces.
|
|
120
|
+
export function stripStringLiterals(cypher: string): string {
|
|
56
121
|
// Replace single- and double-quoted literals with empty quotes. Preserves
|
|
57
122
|
// positional structure without retaining content that could match the
|
|
58
123
|
// label regex (e.g. ':SomeLabel' inside a string).
|
|
@@ -118,19 +183,40 @@ function hintFor(
|
|
|
118
183
|
return `${didYouMean}Unknown ${kind} '${token}' — not in the current Neo4j schema. See .docs/neo4j.md for the canonical taxonomy.`;
|
|
119
184
|
}
|
|
120
185
|
|
|
186
|
+
function detectForbidden(cypher: string): ForbiddenPattern[] {
|
|
187
|
+
// Strip literals so a property value like 'CREATE INDEX archive' cannot
|
|
188
|
+
// trip a false rejection. The shared `stripStringLiterals` keeps this
|
|
189
|
+
// treatment uniform with token extraction and audit static parsing.
|
|
190
|
+
const cleaned = stripStringLiterals(cypher);
|
|
191
|
+
const out: ForbiddenPattern[] = [];
|
|
192
|
+
for (const { kind, pattern, hint } of FORBIDDEN_PATTERNS) {
|
|
193
|
+
const found = cleaned.match(pattern);
|
|
194
|
+
if (found) {
|
|
195
|
+
out.push({ kind, match: found[0], hint });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
121
201
|
export function validate(
|
|
122
202
|
cypher: string,
|
|
123
203
|
snapshot: SchemaSnapshot,
|
|
204
|
+
options: ValidateOptions = {},
|
|
124
205
|
): ValidationResult {
|
|
206
|
+
const mode = options.mode ?? "read";
|
|
207
|
+
const forbidden = mode === "write" ? detectForbidden(cypher) : [];
|
|
125
208
|
const { labels, edges } = extractTokens(cypher);
|
|
126
209
|
// Fail-open when the snapshot is empty. An empty snapshot means "schema
|
|
127
210
|
// cache not loaded" (boot race, Neo4j unreachable). Rejecting every token
|
|
128
211
|
// would wedge the admin agent; letting it through preserves the observable
|
|
129
212
|
// `validated=false` signal on the existing [graph-query] line.
|
|
213
|
+
// Forbidden DDL/admin patterns still apply — those are syntactic, not
|
|
214
|
+
// schema-derived.
|
|
130
215
|
if (snapshot.labels.size === 0 && snapshot.relationshipTypes.size === 0) {
|
|
131
216
|
return {
|
|
132
|
-
ok:
|
|
217
|
+
ok: forbidden.length === 0,
|
|
133
218
|
unknown: [],
|
|
219
|
+
forbidden,
|
|
134
220
|
labelTokens: [...labels],
|
|
135
221
|
edgeTokens: [...edges],
|
|
136
222
|
};
|
|
@@ -148,9 +234,15 @@ export function validate(
|
|
|
148
234
|
unknown.push({ token, kind: "relationship", nearest, hint: hintFor(token, "relationship", nearest) });
|
|
149
235
|
}
|
|
150
236
|
}
|
|
237
|
+
// Write-mode caller treats `unknown` as a soft warning (audit), not a
|
|
238
|
+
// hard reject — operators legitimately introduce new labels via REMOVE
|
|
239
|
+
// n:Old SET n:New. Read-mode preserves prior behaviour: any unknown
|
|
240
|
+
// → ok=false.
|
|
241
|
+
const tokensOk = mode === "write" ? true : unknown.length === 0;
|
|
151
242
|
return {
|
|
152
|
-
ok:
|
|
243
|
+
ok: tokensOk && forbidden.length === 0,
|
|
153
244
|
unknown,
|
|
245
|
+
forbidden,
|
|
154
246
|
labelTokens: [...labels],
|
|
155
247
|
edgeTokens: [...edges],
|
|
156
248
|
};
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
258
|
-
//
|
|
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=${
|
|
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=${
|
|
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 (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
`[
|
|
536
|
+
`[cypher-validate] tool=write outcome=warned unknown=${tokenSummary} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
|
|
422
537
|
);
|
|
423
|
-
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"audit.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/audit.test.ts"],"names":[],"mappings":""}
|