@salesforce/graphiti 11.31.14 → 11.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/intent/build-detail.js +3 -1
  3. package/dist/intent/build-detail.js.map +1 -1
  4. package/dist/intent/build-list.js +8 -1
  5. package/dist/intent/build-list.js.map +1 -1
  6. package/dist/intent/build-mutation.js +6 -1
  7. package/dist/intent/build-mutation.js.map +1 -1
  8. package/dist/intent/select-child-relationship.js +9 -0
  9. package/dist/intent/select-child-relationship.js.map +1 -1
  10. package/dist/lib/graphql-name.d.ts +18 -0
  11. package/dist/lib/graphql-name.js +22 -0
  12. package/dist/lib/graphql-name.js.map +1 -1
  13. package/dist/lib/variable-promotion.js +10 -0
  14. package/dist/lib/variable-promotion.js.map +1 -1
  15. package/dist/schemas/fields.d.ts +10 -0
  16. package/dist/schemas/fields.js +21 -2
  17. package/dist/schemas/fields.js.map +1 -1
  18. package/dist/schemas/input-schemas.js +14 -14
  19. package/dist/schemas/input-schemas.js.map +1 -1
  20. package/package.json +1 -1
  21. package/src/intent/__tests__/build-aggregate.spec.ts +29 -0
  22. package/src/intent/__tests__/build-create-validation.spec.ts +37 -0
  23. package/src/intent/__tests__/build-detail.spec.ts +96 -0
  24. package/src/intent/__tests__/build-list.spec.ts +162 -1
  25. package/src/intent/__tests__/build-update-validation.spec.ts +20 -0
  26. package/src/intent/build-detail.ts +3 -1
  27. package/src/intent/build-list.ts +8 -1
  28. package/src/intent/build-mutation.ts +6 -1
  29. package/src/intent/select-child-relationship.ts +10 -0
  30. package/src/lib/__tests__/graphql-name.spec.ts +72 -7
  31. package/src/lib/graphql-name.ts +26 -0
  32. package/src/lib/variable-promotion.ts +12 -0
  33. package/src/mcp/tools/__tests__/error-surface.contract.spec.ts +23 -13
  34. package/src/schemas/__tests__/input-schemas.spec.ts +153 -0
  35. package/src/schemas/fields.ts +29 -2
  36. package/src/schemas/input-schemas.ts +15 -13
@@ -68,6 +68,7 @@ const SCHEMA_SDL = `
68
68
  Id: ID!
69
69
  Name: StringValue
70
70
  Industry: StringValue
71
+ Amount__c: StringValue
71
72
  Owner: OwnerUnion
72
73
  Contacts(first: Int, where: Contact_Filter, orderBy: Contact_OrderBy): ContactConnection
73
74
  }
@@ -390,3 +391,98 @@ describe("intent/build-detail", () => {
390
391
  expect(out.query).toMatch(/first\s*:\s*\$childFirst\b/);
391
392
  });
392
393
  });
394
+
395
+ describe("intent/build-detail — selection-set injection (W-22735537)", () => {
396
+ it("rejects a fields[] breakout", async () => {
397
+ await expect(
398
+ buildDetail(
399
+ { org: ORG, object: "Account", fields: ["Id } injectedAlias: Name { value"] },
400
+ noopPrimeDeps(),
401
+ ),
402
+ ).rejects.toThrow(/fields entry .* is not a valid field path/);
403
+ });
404
+
405
+ it("rejects a parentFields[] breakout", async () => {
406
+ await expect(
407
+ buildDetail(
408
+ {
409
+ org: ORG,
410
+ object: "Account",
411
+ fields: ["Id"],
412
+ parentFields: ["Owner.Name } evil { value"],
413
+ },
414
+ noopPrimeDeps(),
415
+ ),
416
+ ).rejects.toThrow(/parentFields entry .* is not a valid field path/);
417
+ });
418
+
419
+ it("rejects a childRelationships[].relationshipName breakout", async () => {
420
+ await expect(
421
+ buildDetail(
422
+ {
423
+ org: ORG,
424
+ object: "Account",
425
+ fields: ["Id"],
426
+ childRelationships: [
427
+ { relationshipName: "Contacts } injectedSibling: Account { Id", fields: ["Id"] },
428
+ ],
429
+ },
430
+ noopPrimeDeps(),
431
+ ),
432
+ ).rejects.toThrow(/selectChildRelationship: relationshipName .* is not a valid GraphQL Name/);
433
+ });
434
+
435
+ it("rejects a childRelationships[].fields breakout", async () => {
436
+ await expect(
437
+ buildDetail(
438
+ {
439
+ org: ORG,
440
+ object: "Account",
441
+ fields: ["Id"],
442
+ childRelationships: [
443
+ { relationshipName: "Contacts", fields: ["Id } evil: LastName { value"] },
444
+ ],
445
+ },
446
+ noopPrimeDeps(),
447
+ ),
448
+ ).rejects.toThrow(/childRelationships fields entry .* is not a valid field path/);
449
+ });
450
+
451
+ it("rejects a childRelationships[].filter object-KEY breakout", async () => {
452
+ await expect(
453
+ buildDetail(
454
+ {
455
+ org: ORG,
456
+ object: "Account",
457
+ fields: ["Id"],
458
+ childRelationships: [
459
+ {
460
+ relationshipName: "Contacts",
461
+ fields: ["Id"],
462
+ filter: { "Title } evil { value": { eq: "x" } },
463
+ },
464
+ ],
465
+ },
466
+ noopPrimeDeps(),
467
+ ),
468
+ ).rejects.toThrow(/key '.*' is not a valid GraphQL Name/);
469
+ });
470
+
471
+ it("accepts a legit dotted parentField (Owner.Name)", async () => {
472
+ const out = await buildDetail(
473
+ { org: ORG, object: "Account", fields: ["Id"], parentFields: ["Owner.Name"] },
474
+ noopPrimeDeps(),
475
+ );
476
+ expect(out.query).toContain("Owner");
477
+ });
478
+
479
+ // PR #678 review (Ciaran Hannigan): the __c custom-field accept-case the
480
+ // charset tests assert in isolation, exercised end-to-end through the builder.
481
+ it("accepts a Salesforce custom field (__c) and renders it", async () => {
482
+ const out = await buildDetail(
483
+ { org: ORG, object: "Account", fields: ["Id", "Amount__c"] },
484
+ noopPrimeDeps(),
485
+ );
486
+ expect(out.query).toContain("Amount__c");
487
+ });
488
+ });
@@ -4,7 +4,7 @@
4
4
  * For full license text, see the LICENSE.txt file
5
5
  */
6
6
 
7
- import { buildSchema } from "graphql";
7
+ import { buildSchema, parse } from "graphql";
8
8
  import { describe, expect, it, vi } from "vitest";
9
9
  import { makeNoopPrimeDeps } from "../../__tests__/helpers/prime-deps.js";
10
10
  import * as sessionModule from "../../lib/session.js";
@@ -55,6 +55,7 @@ const SCHEMA_SDL = `
55
55
  Id: ID!
56
56
  Name: StringValue
57
57
  Industry: StringValue
58
+ Amount__c: StringValue
58
59
  Owner: OwnerUnion
59
60
  Contacts(first: Int, where: Contact_Filter, orderBy: Contact_OrderBy): ContactConnection
60
61
  }
@@ -471,3 +472,163 @@ describe("intent/build-list", () => {
471
472
  expect(out.query).toMatch(/first\s*:\s*10\b/);
472
473
  });
473
474
  });
475
+
476
+ describe("intent/build-list — selection-set injection (W-22735537)", () => {
477
+ it("rejects a fields[] selection-set breakout", async () => {
478
+ await expect(
479
+ buildList(
480
+ { org: ORG, object: "Account", fields: ["Id } injectedAlias: Name { value"] },
481
+ noopPrimeDeps(),
482
+ ),
483
+ ).rejects.toThrow(/fields entry .* is not a valid field path/);
484
+ });
485
+
486
+ it("rejects a parentFields[] breakout", async () => {
487
+ await expect(
488
+ buildList(
489
+ {
490
+ org: ORG,
491
+ object: "Account",
492
+ fields: ["Id"],
493
+ parentFields: ["Owner.Name } evil { value"],
494
+ },
495
+ noopPrimeDeps(),
496
+ ),
497
+ ).rejects.toThrow(/parentFields entry .* is not a valid field path/);
498
+ });
499
+
500
+ it("rejects a childRelationships[].fields breakout", async () => {
501
+ await expect(
502
+ buildList(
503
+ {
504
+ org: ORG,
505
+ object: "Account",
506
+ fields: ["Id"],
507
+ childRelationships: [
508
+ { relationshipName: "Contacts", fields: ["Id } evil: LastName { value"] },
509
+ ],
510
+ },
511
+ noopPrimeDeps(),
512
+ ),
513
+ ).rejects.toThrow(/childRelationships fields entry .* is not a valid field path/);
514
+ });
515
+
516
+ it("rejects a childRelationships[].relationshipName breakout", async () => {
517
+ await expect(
518
+ buildList(
519
+ {
520
+ org: ORG,
521
+ object: "Account",
522
+ fields: ["Id"],
523
+ childRelationships: [
524
+ { relationshipName: "Contacts } injectedSibling: Account { Id", fields: ["Id"] },
525
+ ],
526
+ },
527
+ noopPrimeDeps(),
528
+ ),
529
+ ).rejects.toThrow(/selectChildRelationship: relationshipName .* is not a valid GraphQL Name/);
530
+ });
531
+
532
+ it("rejects a filter object-KEY breakout (argument-position injection)", async () => {
533
+ await expect(
534
+ buildList(
535
+ {
536
+ org: ORG,
537
+ object: "Account",
538
+ fields: ["Id"],
539
+ filter: { "Industry } evilField { value } sib: Name(x": { eq: "Tech" } },
540
+ },
541
+ noopPrimeDeps(),
542
+ ),
543
+ ).rejects.toThrow(/key '.*' is not a valid GraphQL Name/);
544
+ });
545
+
546
+ it("rejects an orderBy object-KEY breakout", async () => {
547
+ await expect(
548
+ buildList(
549
+ {
550
+ org: ORG,
551
+ object: "Account",
552
+ fields: ["Id"],
553
+ orderBy: { "Name } evil { value": { order: "ASC" } },
554
+ },
555
+ noopPrimeDeps(),
556
+ ),
557
+ ).rejects.toThrow(/key '.*' is not a valid GraphQL Name/);
558
+ });
559
+
560
+ it("accepts a legit dotted parentField (Owner.Name) and a legit filter", async () => {
561
+ const out = await buildList(
562
+ {
563
+ org: ORG,
564
+ object: "Account",
565
+ fields: ["Id", "Name"],
566
+ parentFields: ["Owner.Name"],
567
+ filter: { Industry: { eq: "Tech" } },
568
+ },
569
+ noopPrimeDeps(),
570
+ );
571
+ expect(out.query).toContain("Owner");
572
+ });
573
+
574
+ // PR #678 review (Ciaran Hannigan): the __c custom-field accept-case the
575
+ // charset tests assert in isolation, exercised end-to-end through the builder.
576
+ it("accepts a Salesforce custom field (__c) and renders it", async () => {
577
+ const out = await buildList(
578
+ { org: ORG, object: "Account", fields: ["Id", "Amount__c"] },
579
+ noopPrimeDeps(),
580
+ );
581
+ expect(out.query).toContain("Amount__c");
582
+ });
583
+
584
+ it("rejects a scope argument breakout", async () => {
585
+ await expect(
586
+ buildList(
587
+ {
588
+ org: ORG,
589
+ object: "Account",
590
+ fields: ["Id"],
591
+ scope: "{}) { Id } injectedSibling: Account(scope: MINE",
592
+ },
593
+ noopPrimeDeps(),
594
+ ),
595
+ ).rejects.toThrow(/buildList: scope .* is not a valid GraphQL Name/);
596
+ });
597
+
598
+ it("accepts a legit scope enum token and a $var scope", async () => {
599
+ const enumOut = await buildList(
600
+ { org: ORG, object: "Account", fields: ["Id"], scope: "EVERYTHING" },
601
+ noopPrimeDeps(),
602
+ );
603
+ expect(enumOut.query).toContain("scope: EVERYTHING");
604
+ const varOut = await buildList(
605
+ { org: ORG, object: "Account", fields: ["Id"], scope: "$myScope" },
606
+ noopPrimeDeps(),
607
+ );
608
+ expect(varOut.query).toContain("$myScope");
609
+ });
610
+
611
+ it("rejects a NESTED filter object-KEY breakout (recursion guard)", async () => {
612
+ await expect(
613
+ buildList(
614
+ {
615
+ org: ORG,
616
+ object: "Account",
617
+ fields: ["Id"],
618
+ filter: { Industry: { "eq } evil { value": "Tech" } },
619
+ },
620
+ noopPrimeDeps(),
621
+ ),
622
+ ).rejects.toThrow(/key '.*' is not a valid GraphQL Name/);
623
+ });
624
+
625
+ it("renders a parseable document with no injected sibling (graphql-js reparse)", async () => {
626
+ const out = await buildList(
627
+ { org: ORG, object: "Account", fields: ["Id", "Name"], filter: { Industry: { eq: "Tech" } } },
628
+ noopPrimeDeps(),
629
+ );
630
+ expect(() => parse(out.query)).not.toThrow();
631
+ expect(out.query).not.toContain("injectedAlias");
632
+ expect(out.query).not.toContain("evil");
633
+ });
634
+ });
@@ -131,4 +131,24 @@ describe("intent/build-update — GraphQL name validation", () => {
131
131
  ).rejects.toThrow(/buildMutation: operationName 'has spaces' is not a valid GraphQL Name/);
132
132
  });
133
133
  });
134
+
135
+ describe("returnFields validation (W-22735537)", () => {
136
+ it("rejects a selection-set breakout payload", async () => {
137
+ await expect(
138
+ buildUpdate(
139
+ { org: ORG, object: "Account", returnFields: ["Id } injectedAlias: Name { value"] },
140
+ noopPrimeDeps(),
141
+ ),
142
+ ).rejects.toThrow(/returnFields entry .* is not a valid field path/);
143
+ });
144
+
145
+ it("accepts a legit dot-path (Owner.Name) — the guard allows dotted segments", async () => {
146
+ await expect(
147
+ buildUpdate(
148
+ { org: ORG, object: "Account", returnFields: ["Id", "Owner.Name"] },
149
+ noopPrimeDeps(),
150
+ ),
151
+ ).resolves.toBeDefined();
152
+ });
153
+ });
134
154
  });
@@ -8,7 +8,7 @@ import { buildOutput } from "./build-output.js";
8
8
  import { getSchemaWithPriming } from "./get-schema-with-priming.js";
9
9
  import { selectChildRelationship } from "./select-child-relationship.js";
10
10
  import { type DetailSpec, type ToolOutput } from "./types.js";
11
- import { assertGraphqlName } from "../lib/graphql-name.js";
11
+ import { assertDottedGraphqlName, assertGraphqlName } from "../lib/graphql-name.js";
12
12
  import { selectDottedFieldPath } from "../lib/path-selection.js";
13
13
  import { type PrimeDeps } from "../lib/prime-schema.js";
14
14
  import { addVariable, createSession, deepSetArg } from "../lib/session.js";
@@ -54,11 +54,13 @@ export async function buildDetail(spec: DetailSpec, deps?: PrimeDeps): Promise<T
54
54
  const node = connectionNodePath(connection);
55
55
 
56
56
  for (const field of spec.fields) {
57
+ assertDottedGraphqlName(field, "buildDetail", "fields entry");
57
58
  selectDottedFieldPath(session, schema, node, field);
58
59
  }
59
60
 
60
61
  if (spec.parentFields) {
61
62
  for (const pf of spec.parentFields) {
63
+ assertDottedGraphqlName(pf, "buildDetail", "parentFields entry");
62
64
  selectDottedFieldPath(session, schema, node, pf);
63
65
  }
64
66
  }
@@ -8,7 +8,7 @@ import { buildOutput } from "./build-output.js";
8
8
  import { getSchemaWithPriming } from "./get-schema-with-priming.js";
9
9
  import { selectChildRelationship } from "./select-child-relationship.js";
10
10
  import { type ListSpec, type ToolOutput } from "./types.js";
11
- import { assertGraphqlName } from "../lib/graphql-name.js";
11
+ import { assertDottedGraphqlName, assertGraphqlName } from "../lib/graphql-name.js";
12
12
  import { selectDottedFieldPath } from "../lib/path-selection.js";
13
13
  import { type PrimeDeps } from "../lib/prime-schema.js";
14
14
  import { addVariable, createSession, deepSetArg, selectLeaf } from "../lib/session.js";
@@ -47,11 +47,13 @@ export async function buildList(spec: ListSpec, deps?: PrimeDeps): Promise<ToolO
47
47
  const nodePath = [...connectionPath, "edges", "node"];
48
48
 
49
49
  for (const field of spec.fields) {
50
+ assertDottedGraphqlName(field, "buildList", "fields entry");
50
51
  selectDottedFieldPath(session, schema, nodePath, field);
51
52
  }
52
53
 
53
54
  if (spec.parentFields) {
54
55
  for (const pf of spec.parentFields) {
56
+ assertDottedGraphqlName(pf, "buildList", "parentFields entry");
55
57
  selectDottedFieldPath(session, schema, nodePath, pf);
56
58
  }
57
59
  }
@@ -103,6 +105,11 @@ export async function buildList(spec: ListSpec, deps?: PrimeDeps): Promise<ToolO
103
105
  }
104
106
 
105
107
  if (spec.scope) {
108
+ // `scope` renders into an argument position and is stored verbatim, so an
109
+ // unconstrained string is a selection-set / argument breakout (W-22735537).
110
+ // Allow a bare enum token (e.g. MINE) or a $varName; reject anything else.
111
+ const scopeName = spec.scope.startsWith("$") ? spec.scope.slice(1) : spec.scope;
112
+ assertGraphqlName(scopeName, "buildList", "scope");
106
113
  promoteVariables(session, schema, connectionPath, "scope", spec.scope, extraWarnings);
107
114
  deepSetArg(session, connectionPath, "scope", [], spec.scope);
108
115
  }
@@ -7,7 +7,7 @@
7
7
  import { buildOutput } from "./build-output.js";
8
8
  import { getSchemaWithPriming } from "./get-schema-with-priming.js";
9
9
  import { type CreateSpec, type ToolOutput, type UpdateSpec } from "./types.js";
10
- import { assertGraphqlName } from "../lib/graphql-name.js";
10
+ import { assertDottedGraphqlName, assertGraphqlName } from "../lib/graphql-name.js";
11
11
  import { selectDottedFieldPath } from "../lib/path-selection.js";
12
12
  import { type PrimeDeps } from "../lib/prime-schema.js";
13
13
  import { addVariable, createSession, deepSetArg } from "../lib/session.js";
@@ -63,6 +63,11 @@ export async function buildMutation(
63
63
 
64
64
  const extraWarnings: string[] = [];
65
65
  for (const field of spec.returnFields ?? ["Id"]) {
66
+ // Validate the charset BEFORE the warn-swallowing try below, so a
67
+ // selection-set breakout (W-22735537) hard-fails instead of being demoted
68
+ // to a non-gating warning. A legit dot-path passes here and is then handled
69
+ // (warn-and-skip) by selectDottedFieldPath as an unsupported mutation-result path.
70
+ assertDottedGraphqlName(field, "buildMutation", "returnFields entry");
66
71
  try {
67
72
  selectDottedFieldPath(session, schema, recordPath, field);
68
73
  } catch (err) {
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { type GraphQLSchema } from "graphql";
8
8
  import { type ChildRelationshipSpec } from "./types.js";
9
+ import { assertDottedGraphqlName, assertGraphqlName } from "../lib/graphql-name.js";
9
10
  import { selectDottedFieldPath } from "../lib/path-selection.js";
10
11
  import { deepSetArg, type QuerySession } from "../lib/session.js";
11
12
  import { normalizeOrderBy, promoteArg } from "../lib/variable-promotion.js";
@@ -27,10 +28,19 @@ export function selectChildRelationship(
27
28
  child: ChildRelationshipSpec,
28
29
  warnings?: string[],
29
30
  ): void {
31
+ // `relationshipName` is appended to the path and rendered verbatim as the
32
+ // connection field name (query-builder emits node.fieldName directly), so an
33
+ // unconstrained value is a selection-set breakout — the same class as `fields`
34
+ // below and `object`/`scope` on the top-level builders. Guard it here too: the
35
+ // MCP + CLI-mirror boundaries already enforce the GraphQL Name charset via zod,
36
+ // but the builders are also reachable directly (CLI, eval harness) (W-22735537).
37
+ assertGraphqlName(child.relationshipName, "selectChildRelationship", "relationshipName");
38
+
30
39
  const childConnectionPath = [...parentNodePath, child.relationshipName];
31
40
  const childNodePath = [...childConnectionPath, "edges", "node"];
32
41
 
33
42
  for (const field of child.fields) {
43
+ assertDottedGraphqlName(field, "selectChildRelationship", "childRelationships fields entry");
34
44
  selectDottedFieldPath(session, schema, childNodePath, field);
35
45
  }
36
46
 
@@ -5,7 +5,12 @@
5
5
  */
6
6
 
7
7
  import { describe, expect, it } from "vitest";
8
- import { assertGraphqlName, GRAPHQL_NAME_RE } from "../graphql-name.js";
8
+ import {
9
+ assertDottedGraphqlName,
10
+ assertGraphqlName,
11
+ DOTTED_GRAPHQL_NAME_RE,
12
+ GRAPHQL_NAME_RE,
13
+ } from "../graphql-name.js";
9
14
 
10
15
  /**
11
16
  * Single source of truth for the GraphQL Name guard shared by every intent
@@ -14,12 +19,18 @@ import { assertGraphqlName, GRAPHQL_NAME_RE } from "../graphql-name.js";
14
19
  */
15
20
  describe("lib/graphql-name", () => {
16
21
  describe("assertGraphqlName", () => {
17
- it.each(["Account", "Custom_Object__c", "_Foo", "_", "a", "a1_2b", "CreateAccount"])(
18
- "accepts the valid GraphQL Name %j",
19
- (value) => {
20
- expect(() => assertGraphqlName(value, "buildX", "operationName")).not.toThrow();
21
- },
22
- );
22
+ it.each([
23
+ "Account",
24
+ "Custom_Object__c",
25
+ "MyNS__Field__c", // namespaced custom field (PR #678 review: __c regression guard)
26
+ "_Foo",
27
+ "_",
28
+ "a",
29
+ "a1_2b",
30
+ "CreateAccount",
31
+ ])("accepts the valid GraphQL Name %j", (value) => {
32
+ expect(() => assertGraphqlName(value, "buildX", "operationName")).not.toThrow();
33
+ });
23
34
 
24
35
  it.each([
25
36
  "", // empty
@@ -61,4 +72,58 @@ describe("lib/graphql-name", () => {
61
72
  expect(GRAPHQL_NAME_RE.test("")).toBe(false);
62
73
  });
63
74
  });
75
+
76
+ // W-22735537: dotted field-path guard for returnFields / fields / parentFields
77
+ // / childRelationships.fields. Each '.'-separated segment must be a GraphQL
78
+ // Name; a breakout string (containing { } : or whitespace) cannot match.
79
+ describe("assertDottedGraphqlName", () => {
80
+ it.each([
81
+ "Id",
82
+ "Name",
83
+ "Owner.Name",
84
+ "CreatedBy.Profile.Name",
85
+ "Amount__c", // Salesforce custom field (PR #678 review: __c regression guard)
86
+ "Owner.Custom__c", // dotted path ending in a custom field
87
+ "MyNS__Field__c", // namespaced custom field (double underscore in the middle)
88
+ "_a._b",
89
+ "a1.b2.c3",
90
+ "_",
91
+ ])("accepts the valid dotted field path %j", (value) => {
92
+ expect(() => assertDottedGraphqlName(value, "buildX", "returnFields entry")).not.toThrow();
93
+ });
94
+
95
+ it.each([
96
+ "", // empty
97
+ ".Name", // leading dot
98
+ "Owner.", // trailing dot
99
+ "Owner..Name", // empty segment
100
+ "1Bad.Name", // segment leading digit
101
+ "Owner.Na-me", // hyphen in segment
102
+ "a b", // space
103
+ "Id } injectedAlias: Name { value", // the W-22735537 selection-set breakout
104
+ "Name { value } evil",
105
+ "Id @skip(if:true)",
106
+ "Owner.Name { value",
107
+ ])("rejects the invalid field path %j", (value) => {
108
+ expect(() => assertDottedGraphqlName(value, "buildX", "returnFields entry")).toThrow(
109
+ /is not a valid field path/,
110
+ );
111
+ });
112
+
113
+ it("throws the exact builder- and field-prefixed message", () => {
114
+ expect(() => assertDottedGraphqlName("a b", "buildMutation", "returnFields entry")).toThrow(
115
+ /^buildMutation: returnFields entry 'a b' is not a valid field path/,
116
+ );
117
+ });
118
+ });
119
+
120
+ describe("DOTTED_GRAPHQL_NAME_RE", () => {
121
+ it("accepts dotted paths and rejects breakout strings", () => {
122
+ expect(DOTTED_GRAPHQL_NAME_RE.test("Owner.Name")).toBe(true);
123
+ expect(DOTTED_GRAPHQL_NAME_RE.test("Id")).toBe(true);
124
+ expect(DOTTED_GRAPHQL_NAME_RE.test("Id } x: y { z")).toBe(false);
125
+ expect(DOTTED_GRAPHQL_NAME_RE.test("Owner.")).toBe(false);
126
+ expect(DOTTED_GRAPHQL_NAME_RE.test("")).toBe(false);
127
+ });
128
+ });
64
129
  });
@@ -33,3 +33,29 @@ export function assertGraphqlName(value: string, builder: string, field: string)
33
33
  );
34
34
  }
35
35
  }
36
+
37
+ /**
38
+ * Dotted field path: one GraphQL Name, then zero or more `.`-separated Names —
39
+ * e.g. `Id`, `Owner.Name`, `CreatedBy.Profile.Name`. Each segment must be a
40
+ * valid GraphQL Name on its own, and only the literal segment-separating dot is
41
+ * allowed between them. Because no segment can contain `{`, `}`, `:`, or
42
+ * whitespace, a string that matches cannot close a selection block early or
43
+ * hoist a sibling selection — closing the W-22735537 selection-set injection
44
+ * where caller-supplied `returnFields` / `fields` / `parentFields` reach the
45
+ * rendered query verbatim.
46
+ */
47
+ export const DOTTED_GRAPHQL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
48
+
49
+ /**
50
+ * Throw if `value` is not a valid dotted field path (see
51
+ * {@link DOTTED_GRAPHQL_NAME_RE}). The intent-layer twin of the zod
52
+ * `dottedGraphqlName()` factory, used by the builders, which are also reachable
53
+ * directly (CLI, eval harness) and so cannot rely on the zod boundary.
54
+ */
55
+ export function assertDottedGraphqlName(value: string, builder: string, field: string): void {
56
+ if (!DOTTED_GRAPHQL_NAME_RE.test(value)) {
57
+ throw new Error(
58
+ `${builder}: ${field} '${value}' is not a valid field path (each '.'-separated segment must be a GraphQL Name matching ${GRAPHQL_NAME_RE})`,
59
+ );
60
+ }
61
+ }
@@ -27,6 +27,7 @@
27
27
  */
28
28
 
29
29
  import { type GraphQLSchema } from "graphql";
30
+ import { GRAPHQL_NAME_RE } from "./graphql-name.js";
30
31
  import { addVariable, type QuerySession } from "./session.js";
31
32
  import { inferTypeFromArgsPath } from "../commands/query-helpers.js";
32
33
 
@@ -127,6 +128,17 @@ function walk(
127
128
  }
128
129
  if (value && typeof value === "object") {
129
130
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
131
+ // Object keys are rendered RAW as field/operator names by
132
+ // query-builder's valueToGraphQL (input-object keys cannot be quoted in
133
+ // GraphQL), so an unvalidated key like `Id: {eq:"1"}}) { evil { value }` is
134
+ // a selection-set / argument breakout. Validate every key (at every depth)
135
+ // as a GraphQL Name and hard-fail — values are separately quoted and safe.
136
+ // This is the Class-B half of W-22735537; runs before deepSetArg renders it.
137
+ if (!GRAPHQL_NAME_RE.test(k)) {
138
+ throw new Error(
139
+ `${argName}: key '${k}' is not a valid GraphQL Name (must match ${GRAPHQL_NAME_RE}); filter/orderBy keys render as field/operator names in the query.`,
140
+ );
141
+ }
130
142
  walk(session, schema, fieldSchemaPath, argName, v, [...pathInsideArg, k], warnings);
131
143
  }
132
144
  }
@@ -147,28 +147,38 @@ describe("mcp/tools error surface — category prefixes (contract)", () => {
147
147
  }
148
148
  });
149
149
 
150
- // A second, independent reflection path through a DIFFERENT tool (sf_gql_list)
151
- // and a DIFFERENT throw site (the walker's union-navigation check in walker.ts,
152
- // not build-raw.ts) \u2014 proving the chokepoint covers the whole error surface, not
153
- // just one tool. A `parentFields` segment that fails navigation is reflected
154
- // verbatim as `Field "<seg>" not found on any member of union ...`; an embedded
155
- // newline + ANSI ESC must reach the host escaped, not raw.
156
- it("UserInput: a control-char field name reflected by walker navigation is neutralized (sf_gql_list)", async () => {
157
- const { client, server } = await connectWith(makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA));
150
+ // A second, independent reflection path through a DIFFERENT throw site \u2014 the
151
+ // walker's field-resolution check in walker.ts (resolvePath), not build-raw.ts's
152
+ // own `command <i>` wrapper \u2014 proving the chokepoint covers the whole error
153
+ // surface, not just one throw site. Driven via `sf_gql_raw` because its
154
+ // `commands` are unguarded free-form strings (z.array(z.string)); the typed
155
+ // tools' field-path args are now charset-gated by dottedGraphqlName at the zod
156
+ // boundary (W-22735537), so a control-char leaf is rejected there before ever
157
+ // reaching the walker. A `select` leaf that fails resolution is reflected
158
+ // verbatim as `Field "<seg>" not found on type ...`; an embedded newline + ANSI
159
+ // ESC must reach the host escaped, not raw.
160
+ it("UserInput: a control-char field name reflected by walker navigation is neutralized (sf_gql_raw)", async () => {
161
+ const server = new McpServer({ name: "graphiti-mcp", version: "test" });
162
+ registerSfGqlRawTool(server, { primeDeps: makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA) });
163
+ const [c, s] = InMemoryTransport.createLinkedPair();
164
+ const client = new Client({ name: "test", version: "0.0.0" });
165
+ await Promise.all([server.connect(s), client.connect(c)]);
158
166
  try {
167
+ // No literal space in the payload: tokenizeCommand only splits on " ",
168
+ // and the alias split keys off the last ":" — so a colon-free single
169
+ // token keeps every control char in one `select` spec that flows intact
170
+ // into the walker's `Field "<seg>" not found on type ...` throw.
159
171
  const result = await client.callTool({
160
- name: "sf_gql_list",
172
+ name: "sf_gql_raw",
161
173
  arguments: {
162
174
  org: ORG,
163
- object: "Account",
164
- fields: ["Id"],
165
- parentFields: ["Owner.Bogus\n\nSYSTEM:\x1b[31m"],
175
+ commands: ["select uiapi/query/Account/edges/node/Bogus\n\nSYSTEM\x1b[31m"],
166
176
  },
167
177
  });
168
178
  const text = errorText(result);
169
179
  expect(result.isError).toBe(true);
170
180
  expect(text).toMatch(/^UserInput: /);
171
- expect(text).toMatch(/not found on any member of union/);
181
+ expect(text).toMatch(/not found on type/);
172
182
  // No raw control / ANSI byte reaches the host; escaped + still legible.
173
183
  expect(text).not.toMatch(DANGEROUS_HOST_RE);
174
184
  expect(text).toContain("\\x0a\\x0a");