@salesforce/graphiti 11.34.0 → 11.34.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/graphiti",
3
- "version": "11.34.0",
3
+ "version": "11.34.2",
4
4
  "description": "Progressive GraphQL query builder CLI for Salesforce orgs",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "type": "module",
@@ -57,7 +57,12 @@ export interface MirrorCommand {
57
57
  function defineMirror<T>(
58
58
  name: string,
59
59
  summary: string,
60
- schema: z.ZodType<T>,
60
+ // Input type is `unknown`, not `T`: the schema validates a raw parsed-JSON
61
+ // blob, and some fields wrap the value in a `z.preprocess` (jsonCoercible,
62
+ // enumStripControlChars) whose Zod input type is `unknown`. Only the OUTPUT
63
+ // must be `T`. Pinning the input to `T` (the default `z.ZodType<T>`) would
64
+ // reject those preprocess-wrapped schemas.
65
+ schema: z.ZodType<T, z.ZodTypeDef, unknown>,
61
66
  build: (input: T) => Promise<unknown>,
62
67
  ): MirrorCommand {
63
68
  return { name, summary, run: (jsonArg) => runMirror(jsonArg, schema, build) };
@@ -88,7 +88,10 @@ function emitError(code: MirrorErrorCode, message: string, details?: unknown): v
88
88
  */
89
89
  export async function runMirror<T>(
90
90
  jsonArg: string | undefined,
91
- schema: z.ZodType<T>,
91
+ // Input type is `unknown` (a raw parsed-JSON blob), not `T`: `z.preprocess`-
92
+ // wrapped fields (jsonCoercible, enumStripControlChars) have a Zod input type
93
+ // of `unknown`, so only the parsed OUTPUT is `T`. See defineMirror in commands.ts.
94
+ schema: z.ZodType<T, z.ZodTypeDef, unknown>,
92
95
  build: (input: T) => Promise<unknown>,
93
96
  deps: RunMirrorDeps = {},
94
97
  ): Promise<void> {
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+
7
+ import { describe, expect, it } from "vitest";
8
+ import {
9
+ CONTROL_CHAR_RE,
10
+ LINE_SEPARATOR_RE,
11
+ stripControlChars,
12
+ stripLineSeparators,
13
+ } from "../control-chars.js";
14
+
15
+ /**
16
+ * Single source of truth for the Unicode control (Cc) + format (Cf) class shared
17
+ * by the two host-visible-text sinks: the error-envelope escaper
18
+ * (schemas/tool-adapter.ts neutralizeControlChars) and the enum-rejection
19
+ * stripper (schemas/fields.ts enumStripControlChars, W-23336443). The exhaustive
20
+ * membership matrix lives here; each consumer's spec keeps just the cases that
21
+ * prove its DISPOSITION (escape vs strip). Every dangerous char is written as a
22
+ * \u escape (house style, matching tool-adapter.spec.ts) so the source carries
23
+ * no invisible bytes and stays reviewable; only printable Unicode that must be
24
+ * PRESERVED (accents, CJK) is written as a literal.
25
+ */
26
+ describe("lib/control-chars", () => {
27
+ // One representative per sub-class. stripControlChars output carries no JSON
28
+ // pretty-printing, so a whole-class assertion over the RESULT is exact here
29
+ // (unlike an end-to-end SDK envelope, whose pretty-print newlines are
30
+ // themselves Cc -- there we must assert specific injected code points).
31
+ const MEMBERS: [string, string][] = [
32
+ ["C0 LF", "\n"],
33
+ ["C0 CR", "\r"],
34
+ ["C0 TAB", "\t"],
35
+ ["C0 NUL", "\x00"],
36
+ ["C0 ESC", "\x1b"],
37
+ ["DEL", "\x7f"],
38
+ ["C1 low (PAD)", "\x80"],
39
+ ["C1 CSI", "\x9b"],
40
+ ["NEL", "\u{85}"],
41
+ ["soft hyphen (Cf)", "\u{ad}"],
42
+ ["Arabic Letter Mark (Cf, Bidi_Control)", "\u{61c}"],
43
+ ["ZWSP", "\u{200b}"],
44
+ ["ZWNJ", "\u{200c}"],
45
+ ["ZWJ", "\u{200d}"],
46
+ ["word joiner", "\u{2060}"],
47
+ ["bidi RLO", "\u{202e}"],
48
+ ["bidi LRI", "\u{2066}"],
49
+ ["invisible times", "\u{2062}"],
50
+ ["interlinear anchor", "\u{fff9}"],
51
+ ["BOM/ZWNBSP", "\u{feff}"],
52
+ ["tag block (astral)", "\u{e0001}"],
53
+ ];
54
+
55
+ describe("CONTROL_CHAR_RE", () => {
56
+ it.each(MEMBERS)("matches %s", (_label, ch) => {
57
+ // CONTROL_CHAR_RE carries the `g` flag, so `.test()` is stateful --
58
+ // reset lastIndex before probing.
59
+ CONTROL_CHAR_RE.lastIndex = 0;
60
+ expect(CONTROL_CHAR_RE.test(ch)).toBe(true);
61
+ });
62
+
63
+ it.each([
64
+ ["ASCII letter", "A"],
65
+ ["digit", "7"],
66
+ ["space", " "],
67
+ ["accented", "café"],
68
+ ["CJK", "日本語"],
69
+ ["emoji (astral)", "\u{1f600}"],
70
+ ["line separator U+2028 (Zl, not Cc/Cf)", "\u{2028}"],
71
+ ["paragraph separator U+2029 (Zp, not Cc/Cf)", "\u{2029}"],
72
+ ])("does NOT match %s", (_label, ch) => {
73
+ CONTROL_CHAR_RE.lastIndex = 0;
74
+ expect(CONTROL_CHAR_RE.test(ch)).toBe(false);
75
+ });
76
+
77
+ it("carries the g + u flags (required by both consumers' .replace)", () => {
78
+ expect(CONTROL_CHAR_RE.flags).toContain("g");
79
+ expect(CONTROL_CHAR_RE.flags).toContain("u");
80
+ });
81
+ });
82
+
83
+ describe("stripControlChars", () => {
84
+ it.each(MEMBERS)("deletes %s entirely", (_label, ch) => {
85
+ expect(stripControlChars(`a${ch}b`)).toBe("ab");
86
+ });
87
+
88
+ it("deletes every occurrence, not just the first", () => {
89
+ expect(stripControlChars("a\u{202e}b\u{200b}c\x7fd")).toBe("abcd");
90
+ });
91
+
92
+ it("leaves ordinary Unicode (accents, CJK, emoji) untouched", () => {
93
+ const ok = "plain ASCII café 日本語 naïve \u{1f600}";
94
+ expect(stripControlChars(ok)).toBe(ok);
95
+ });
96
+
97
+ it("leaves U+2028/U+2029 untouched (Zl/Zp are out of scope here)", () => {
98
+ expect(stripControlChars("a\u{2028}b\u{2029}c")).toBe("a\u{2028}b\u{2029}c");
99
+ });
100
+
101
+ it("strips a value to its intended enum member (the W-23336443 case)", () => {
102
+ // A bidi-poisoned-but-otherwise-valid enum value strips to the clean
103
+ // member, so validation ACCEPTS it (no reflected message at all).
104
+ expect(stripControlChars("describe_object\u{202e}")).toBe("describe_object");
105
+ // A genuinely-invalid poisoned value strips to an inert token -- the
106
+ // rejection message it produces downstream carries no raw control char.
107
+ expect(stripControlChars("\u{200b}bogus\x7f")).toBe("bogus");
108
+ });
109
+
110
+ it("output of any stripped string contains no Cc/Cf code point", () => {
111
+ // Whole-class assertion is valid on the RAW stripped output (no JSON
112
+ // pretty-print), so this locks round-trip completeness directly.
113
+ const poison = MEMBERS.map(([, ch]) => ch).join("x");
114
+ const cleaned = stripControlChars(poison);
115
+ expect(cleaned).not.toMatch(/[\p{Cc}\p{Cf}]/u);
116
+ expect(cleaned).toBe("x".repeat(MEMBERS.length - 1));
117
+ });
118
+ });
119
+
120
+ // U+2028/U+2029 are Zl/Zp -- NOT part of CONTROL_CHAR_RE (asserted above) --
121
+ // so they get their own primitive. Every host-visible sink strips them
122
+ // separately because raw, they trip a Claude.AI 408 (MCP TS SDK #2155): the
123
+ // envelope in tool-adapter.ts and, upstream of it, the enum-rejection path
124
+ // (fields.ts enumStripControlChars, W-23336443).
125
+ describe("LINE_SEPARATOR_RE / stripLineSeparators", () => {
126
+ it.each([
127
+ ["line separator U+2028 (Zl)", "\u{2028}"],
128
+ ["paragraph separator U+2029 (Zp)", "\u{2029}"],
129
+ ])("matches + deletes %s", (_label, ch) => {
130
+ LINE_SEPARATOR_RE.lastIndex = 0;
131
+ expect(LINE_SEPARATOR_RE.test(ch)).toBe(true);
132
+ expect(stripLineSeparators(`a${ch}b`)).toBe("ab");
133
+ });
134
+
135
+ it.each([
136
+ ["ASCII newline (a Cc, handled by the other primitive)", "\n"],
137
+ ["ordinary space", " "],
138
+ ["accented", "café"],
139
+ ["bidi RLO (a Cf, handled by the other primitive)", "\u{202e}"],
140
+ ])("does NOT match %s (out of the Zl/Zp scope)", (_label, ch) => {
141
+ LINE_SEPARATOR_RE.lastIndex = 0;
142
+ expect(LINE_SEPARATOR_RE.test(ch)).toBe(false);
143
+ });
144
+
145
+ it("deletes every occurrence, leaving ordinary text intact", () => {
146
+ expect(stripLineSeparators("a\u{2028}b\u{2029}c\u{2028}d")).toBe("abcd");
147
+ const ok = "plain café 日本語";
148
+ expect(stripLineSeparators(ok)).toBe(ok);
149
+ });
150
+
151
+ it("carries the g flag (required by .replace to hit every occurrence)", () => {
152
+ expect(LINE_SEPARATOR_RE.flags).toContain("g");
153
+ });
154
+ });
155
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+
7
+ /**
8
+ * Canonical Unicode control (Cc: C0/C1/DEL) + format (Cf) character class
9
+ * (W-23148363, OWASP LLM01). Left raw in host-visible MCP text, these code
10
+ * points let caller-supplied input forge structure or hide/reorder content:
11
+ * newlines/CR fabricate "SYSTEM:"-style lines, ESC (U+001B) opens ANSI/SGR
12
+ * sequences, NEL (U+0085) and the C1 8-bit CSI (U+009B) are alternate
13
+ * line/escape introducers, and the Cf set (bidi overrides/marks/isolates incl
14
+ * U+061C, zero-width joiners/spaces, word joiner, BOM, invisible-math
15
+ * operators, soft hyphen, and the U+E0000–E007F "tag" smuggling block)
16
+ * reorders or hides text.
17
+ *
18
+ * Matched by Unicode property escape so the set tracks future Cc/Cf additions
19
+ * rather than a hand-enumerated range that drifts (an earlier explicit range
20
+ * silently missed U+061C / ZWNJ / ZWJ / tag chars — all Cf). This is the SINGLE
21
+ * SOURCE OF TRUTH for the class: the error-envelope escaper
22
+ * (`schemas/tool-adapter.ts` `neutralizeControlChars`) and the enum-rejection
23
+ * stripper (`schemas/fields.ts` `enumStripControlChars`, W-23336443) both
24
+ * consume it so the two sinks cannot diverge.
25
+ *
26
+ * NOTE the two consumers differ in DISPOSITION, deliberately:
27
+ * - the error envelope ESCAPES (each char → a visible `\xNN`/`\uNNNN` literal)
28
+ * so a reflected byte stays debuggable but inert;
29
+ * - the enum-rejection path STRIPS (deletes the char) — a z.enum only ever
30
+ * reflects the invalid value verbatim inside its own message, so there is
31
+ * nothing to preserve, and stripping keeps the advertised allowed-value list
32
+ * (which contains no control chars) an exact round-trip.
33
+ * U+2028/U+2029 are intentionally OUT of this Cc/Cf class (they are Zl/Zp, not
34
+ * Cc/Cf); every host-visible sink strips them SEPARATELY via the sibling
35
+ * {@link LINE_SEPARATOR_RE} / {@link stripLineSeparators} below.
36
+ */
37
+ export const CONTROL_CHAR_RE = /[\p{Cc}\p{Cf}]/gu;
38
+
39
+ /**
40
+ * Delete every {@link CONTROL_CHAR_RE} code point from `s`. Used to sanitize a
41
+ * value BEFORE it can be reflected verbatim into a validation-rejection message
42
+ * that the MCP SDK emits UPSTREAM of the tool adapter (W-23336443), where the
43
+ * adapter's escaping neutralizer never runs. Ordinary Unicode (accented names,
44
+ * CJK labels) is untouched.
45
+ */
46
+ export function stripControlChars(s: string): string {
47
+ return s.replace(CONTROL_CHAR_RE, "");
48
+ }
49
+
50
+ /**
51
+ * Unicode line/paragraph separators (U+2028 Zl / U+2029 Zp). These are legal in
52
+ * JSON strings and are NOT escaped by `JSON.stringify`, and left raw in
53
+ * host-visible MCP text they trip a Claude.AI 408 timeout (MCP TS SDK #2155).
54
+ * They are NOT part of {@link CONTROL_CHAR_RE} (that class is Cc/Cf, and its
55
+ * escaper consumer emits `\xNN`/`\uNNNN` — a disposition that would be wrong for
56
+ * these). Every host-visible sink therefore strips them separately: the error/
57
+ * success envelopes (`schemas/tool-adapter.ts`) and the enum-rejection path
58
+ * (`schemas/fields.ts` `enumStripControlChars`, W-23336443), which reaches the
59
+ * host UPSTREAM of the envelope's own strip. Shared here so those sinks agree.
60
+ */
61
+ export const LINE_SEPARATOR_RE = /[\u2028\u2029]/g;
62
+
63
+ /** Delete every {@link LINE_SEPARATOR_RE} code point from `s`. */
64
+ export function stripLineSeparators(s: string): string {
65
+ return s.replace(LINE_SEPARATOR_RE, "");
66
+ }
@@ -413,3 +413,112 @@ describe("mcp/tools/sf-gql-aggregate", () => {
413
413
  }
414
414
  });
415
415
  });
416
+
417
+ // W-23336443: the groupBy `function` enum is a PLAIN z.union member, so it is
418
+ // wrapped in enumStripControlChars (a plain enum's invalid_enum_value echoes the
419
+ // received value verbatim through the SDK's upstream validation). The aggregate
420
+ // `function` DISCRIMINATOR is deliberately NOT wrapped — a bad discriminator
421
+ // raises invalid_union_discriminator, which lists only the expected options and
422
+ // does NOT echo the input, so there is no reflection channel to close. Both
423
+ // claims are proven end-to-end through the real client.callTool channel below.
424
+ // Oracle: assert the specific injected code points absent, not a whole-class
425
+ // scan (the SDK pretty-prints issues with \p{Cc} newlines). See
426
+ // sf-gql-discover.spec.ts for the full rationale.
427
+ describe("mcp/tools/sf-gql-aggregate — control-char neutralization (W-23336443)", () => {
428
+ const BIDI = "\u{202e}";
429
+ const ZWSP = "\u{200b}";
430
+ const DEL = "\x7f";
431
+
432
+ it("a poisoned-but-valid groupBy function strips to the member and is ACCEPTED", async () => {
433
+ const { client, server } = await connect();
434
+ try {
435
+ const result = await client.callTool({
436
+ name: "sf_gql_aggregate",
437
+ arguments: {
438
+ org: ORG,
439
+ object: "Account",
440
+ groupBy: [{ field: "Industry", function: `CALENDAR_MONTH${BIDI}` }],
441
+ aggregations: [{ function: "count" }],
442
+ },
443
+ });
444
+ expect(result.isError).toBeFalsy();
445
+ const content = result.content as { type: string; text?: string }[];
446
+ const parsed = JSON.parse(content[0]?.text ?? "{}") as { query: string };
447
+ // Stripped to CALENDAR_MONTH, which renders as the date-bucket function.
448
+ expect(parsed.query).toMatch(/function\s*:\s*CALENDAR_MONTH\b/s);
449
+ } finally {
450
+ await client.close();
451
+ await server.close();
452
+ }
453
+ });
454
+
455
+ it("a genuinely-invalid poisoned groupBy function rejects with NO raw injected code point", async () => {
456
+ const { client, server } = await connect();
457
+ try {
458
+ const result = await client.callTool({
459
+ name: "sf_gql_aggregate",
460
+ arguments: {
461
+ org: ORG,
462
+ object: "Account",
463
+ groupBy: [{ field: "Industry", function: `${BIDI}bo${ZWSP}gus${DEL}` }],
464
+ aggregations: [{ function: "count" }],
465
+ },
466
+ });
467
+ expect(result.isError).toBe(true);
468
+ const text = (result.content as { text?: string }[])[0]?.text ?? "";
469
+ expect(text).not.toContain(BIDI);
470
+ expect(text).not.toContain(ZWSP);
471
+ expect(text).not.toContain(DEL);
472
+ expect(text).toContain("bogus");
473
+ } finally {
474
+ await client.close();
475
+ await server.close();
476
+ }
477
+ });
478
+
479
+ it("the un-wrapped aggregate function DISCRIMINATOR leaks no injected code point either", async () => {
480
+ const { client, server } = await connect();
481
+ try {
482
+ // Negative control: a poisoned discriminator is NOT stripped (the
483
+ // discriminator is intentionally left un-wrapped), yet the SDK's
484
+ // invalid_union_discriminator issue lists only the expected options and
485
+ // never echoes the received value — so nothing leaks regardless.
486
+ const result = await client.callTool({
487
+ name: "sf_gql_aggregate",
488
+ arguments: {
489
+ org: ORG,
490
+ object: "Account",
491
+ aggregations: [{ function: `count${BIDI}${ZWSP}${DEL}`, field: "Amount" }],
492
+ },
493
+ });
494
+ expect(result.isError).toBe(true);
495
+ const text = (result.content as { text?: string }[])[0]?.text ?? "";
496
+ expect(text).not.toContain(BIDI);
497
+ expect(text).not.toContain(ZWSP);
498
+ expect(text).not.toContain(DEL);
499
+ } finally {
500
+ await client.close();
501
+ await server.close();
502
+ }
503
+ });
504
+
505
+ it("tools/list still advertises the groupBy function enum after wrapping", async () => {
506
+ const { client, server } = await connect();
507
+ try {
508
+ const list = await client.listTools();
509
+ const tool = list.tools.find((t) => t.name === "sf_gql_aggregate");
510
+ // groupBy is an array whose items are a union; the object branch carries
511
+ // the wrapped `function` enum. Assert the enum survives somewhere in the
512
+ // advertised groupBy schema (converter shape varies: anyOf/items).
513
+ const groupBy = (tool!.inputSchema as { properties: Record<string, unknown> }).properties
514
+ .groupBy;
515
+ const serialized = JSON.stringify(groupBy);
516
+ for (const fn of ["CALENDAR_MONTH", "FISCAL_YEAR", "WEEK_IN_YEAR"]) {
517
+ expect(serialized).toContain(fn);
518
+ }
519
+ } finally {
520
+ await client.close();
521
+ await server.close();
522
+ }
523
+ });
524
+ });
@@ -318,3 +318,82 @@ describe("mcp/tools/sf-gql-discover", () => {
318
318
  }
319
319
  });
320
320
  });
321
+
322
+ // W-23336443: the `mode` enum is wrapped in enumStripControlChars. A z.enum
323
+ // rejection is reflected VERBATIM by the MCP SDK's input validation, which runs
324
+ // UPSTREAM of runTool — so the tool adapter's neutralizeControlChars never sees
325
+ // it. These tests exercise the REAL SDK channel (client.callTool round-trip),
326
+ // which is the actual vulnerable path, not a bare schema.safeParse.
327
+ //
328
+ // Oracle discipline: the SDK pretty-prints zod issues with JSON.stringify(_, 2),
329
+ // which inserts real newlines (themselves \p{Cc}) into the envelope, so a
330
+ // whole-class /[\p{Cc}\p{Cf}]/ scan false-positives. We assert instead that the
331
+ // SPECIFIC injected code points (U+202E bidi, U+200B ZWSP, U+007F DEL — the trio
332
+ // the SDK's JSON.stringify leaves raw, since it escapes only C0) are absent.
333
+ describe("mcp/tools/sf-gql-discover — control-char neutralization (W-23336443)", () => {
334
+ // One representative per surviving class. C0 (e.g. \n) is escaped by the SDK's
335
+ // JSON.stringify already; these three are the ones that survived raw pre-fix.
336
+ const BIDI = "\u{202e}";
337
+ const ZWSP = "\u{200b}";
338
+ const DEL = "\x7f";
339
+
340
+ it("a poisoned-but-otherwise-valid mode strips to the member and is ACCEPTED", async () => {
341
+ const { client, server } = await connect();
342
+ try {
343
+ // "describe_object" + trailing bidi override. Pre-fix this rejected and
344
+ // reflected the raw U+202E; post-fix it strips to the valid member and
345
+ // proceeds (the Account fixture describe succeeds).
346
+ const result = await client.callTool({
347
+ name: "sf_gql_discover",
348
+ arguments: { org: ORG, mode: `describe_object${BIDI}`, object: "Account" },
349
+ });
350
+ expect(result.isError).toBeFalsy();
351
+ const content = result.content as { type: string; text?: string }[];
352
+ const parsed = JSON.parse(content[0]?.text ?? "{}") as { mode: string };
353
+ expect(parsed.mode).toBe("describe_object");
354
+ } finally {
355
+ await client.close();
356
+ await server.close();
357
+ }
358
+ });
359
+
360
+ it("a genuinely-invalid poisoned mode rejects with NO raw injected code point", async () => {
361
+ const { client, server } = await connect();
362
+ try {
363
+ // "bogus" wrapped in bidi + zero-width + DEL. Strips to "bogus", which is
364
+ // not a member, so the SDK rejects — but the reflected value is stripped.
365
+ const result = await client.callTool({
366
+ name: "sf_gql_discover",
367
+ arguments: { org: ORG, mode: `${BIDI}bo${ZWSP}gus${DEL}` },
368
+ });
369
+ expect(result.isError).toBe(true);
370
+ const text = (result.content as { text?: string }[])[0]?.text ?? "";
371
+ expect(text).not.toContain(BIDI);
372
+ expect(text).not.toContain(ZWSP);
373
+ expect(text).not.toContain(DEL);
374
+ // It still reflects the stripped token so the LLM can self-correct.
375
+ expect(text).toContain("bogus");
376
+ } finally {
377
+ await client.close();
378
+ await server.close();
379
+ }
380
+ });
381
+
382
+ it("tools/list still advertises the mode enum + description after wrapping", async () => {
383
+ const { client, server } = await connect();
384
+ try {
385
+ const list = await client.listTools();
386
+ const tool = list.tools.find((t) => t.name === "sf_gql_discover");
387
+ const mode = (tool!.inputSchema as { properties: Record<string, unknown> }).properties
388
+ .mode as { enum?: string[]; description?: string };
389
+ expect(mode.enum).toEqual(["list_objects", "describe_object", "describe_field"]);
390
+ expect(mode.description).toMatch(/Discovery mode/);
391
+ // mode stays REQUIRED through the preprocess wrapper.
392
+ const required = (tool!.inputSchema as { required?: string[] }).required ?? [];
393
+ expect(required).toContain("mode");
394
+ } finally {
395
+ await client.close();
396
+ await server.close();
397
+ }
398
+ });
399
+ });
@@ -158,3 +158,94 @@ describe("mcp/server registers sf_gql_raw", () => {
158
158
  }
159
159
  });
160
160
  });
161
+
162
+ // W-23336443: the OPTIONAL `operation` enum is wrapped in enumStripControlChars.
163
+ // A z.enum rejection is reflected verbatim by the MCP SDK's input validation,
164
+ // upstream of runTool. These exercise the real client.callTool channel; the
165
+ // oracle asserts the specific injected code points (U+202E/U+200B/U+007F) are
166
+ // absent, not a whole-class scan (the SDK's JSON.stringify(issues, 2) inserts
167
+ // \p{Cc} newlines that would false-positive). See sf-gql-discover.spec.ts.
168
+ describe("mcp/tools/sf-gql-raw — control-char neutralization (W-23336443)", () => {
169
+ const BIDI = "\u{202e}";
170
+ const ZWSP = "\u{200b}";
171
+ const DEL = "\x7f";
172
+
173
+ it("a poisoned-but-otherwise-valid operation strips to the member and is ACCEPTED", async () => {
174
+ const { client, server } = await connect();
175
+ try {
176
+ const result = await client.callTool({
177
+ name: "sf_gql_raw",
178
+ arguments: {
179
+ org: ORG,
180
+ commands: ["select uiapi/query/Case/edges/node/Subject/value"],
181
+ operation: `query${BIDI}`,
182
+ },
183
+ });
184
+ // "query" is the default root anyway, so a strip-to-"query" renders the
185
+ // same query a clean call would — the value passed validation cleanly.
186
+ expect(result.isError).toBeFalsy();
187
+ const content = result.content as { type: string; text?: string }[];
188
+ const parsed = JSON.parse(content[0]?.text ?? "{}") as { query: string };
189
+ expect(parsed.query).toMatch(/Subject\s+@optional\s*\{\s*value\s*\}/);
190
+ } finally {
191
+ await client.close();
192
+ await server.close();
193
+ }
194
+ });
195
+
196
+ it("a genuinely-invalid poisoned operation rejects with NO raw injected code point", async () => {
197
+ const { client, server } = await connect();
198
+ try {
199
+ const result = await client.callTool({
200
+ name: "sf_gql_raw",
201
+ arguments: {
202
+ org: ORG,
203
+ commands: ["select uiapi/query/Case/edges/node/Id"],
204
+ operation: `${BIDI}bo${ZWSP}gus${DEL}`,
205
+ },
206
+ });
207
+ expect(result.isError).toBe(true);
208
+ const text = (result.content as { text?: string }[])[0]?.text ?? "";
209
+ expect(text).not.toContain(BIDI);
210
+ expect(text).not.toContain(ZWSP);
211
+ expect(text).not.toContain(DEL);
212
+ expect(text).toContain("bogus");
213
+ } finally {
214
+ await client.close();
215
+ await server.close();
216
+ }
217
+ });
218
+
219
+ it("omitting operation still validates (the wrapper preserves .optional())", async () => {
220
+ const { client, server } = await connect();
221
+ try {
222
+ const result = await client.callTool({
223
+ name: "sf_gql_raw",
224
+ arguments: { org: ORG, commands: ["select uiapi/query/Case/edges/node/Id"] },
225
+ });
226
+ expect(result.isError).toBeFalsy();
227
+ } finally {
228
+ await client.close();
229
+ await server.close();
230
+ }
231
+ });
232
+
233
+ it("tools/list advertises the operation enum and keeps it OPTIONAL", async () => {
234
+ const { client, server } = await connect();
235
+ try {
236
+ const list = await client.listTools();
237
+ const tool = list.tools.find((t) => t.name === "sf_gql_raw");
238
+ const schema = tool!.inputSchema as {
239
+ properties: Record<string, unknown>;
240
+ required?: string[];
241
+ };
242
+ const op = schema.properties.operation as { enum?: string[]; description?: string };
243
+ expect(op.enum).toEqual(["query", "mutation", "aggregate"]);
244
+ expect(op.description).toMatch(/Operation root/);
245
+ expect(schema.required ?? []).not.toContain("operation");
246
+ } finally {
247
+ await client.close();
248
+ await server.close();
249
+ }
250
+ });
251
+ });