@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/CHANGELOG.md +10 -0
- package/dist/commands/mcp-mirror/commands.js +7 -1
- package/dist/commands/mcp-mirror/commands.js.map +1 -1
- package/dist/commands/mcp-mirror/run-mirror.d.ts +1 -1
- package/dist/commands/mcp-mirror/run-mirror.js +5 -1
- package/dist/commands/mcp-mirror/run-mirror.js.map +1 -1
- package/dist/lib/control-chars.d.ts +58 -0
- package/dist/lib/control-chars.js +63 -0
- package/dist/lib/control-chars.js.map +1 -0
- package/dist/schemas/fields.d.ts +41 -0
- package/dist/schemas/fields.js +42 -0
- package/dist/schemas/fields.js.map +1 -1
- package/dist/schemas/input-schemas.d.ts +7 -7
- package/dist/schemas/input-schemas.js +22 -11
- package/dist/schemas/input-schemas.js.map +1 -1
- package/dist/schemas/tool-adapter.d.ts +1 -1
- package/dist/schemas/tool-adapter.js +13 -24
- package/dist/schemas/tool-adapter.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/mcp-mirror/commands.ts +6 -1
- package/src/commands/mcp-mirror/run-mirror.ts +4 -1
- package/src/lib/__tests__/control-chars.spec.ts +155 -0
- package/src/lib/control-chars.ts +66 -0
- package/src/mcp/tools/__tests__/sf-gql-aggregate.spec.ts +109 -0
- package/src/mcp/tools/__tests__/sf-gql-discover.spec.ts +79 -0
- package/src/mcp/tools/__tests__/sf-gql-raw.spec.ts +91 -0
- package/src/schemas/__tests__/fields.spec.ts +182 -0
- package/src/schemas/fields.ts +47 -0
- package/src/schemas/input-schemas.ts +30 -14
- package/src/schemas/tool-adapter.ts +13 -25
package/package.json
CHANGED
|
@@ -57,7 +57,12 @@ export interface MirrorCommand {
|
|
|
57
57
|
function defineMirror<T>(
|
|
58
58
|
name: string,
|
|
59
59
|
summary: string,
|
|
60
|
-
schema
|
|
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
|
-
|
|
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
|
+
});
|