@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.
@@ -0,0 +1,182 @@
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 { z } from "zod";
9
+ import { enumStripControlChars } from "../fields.js";
10
+
11
+ /**
12
+ * Unit-level contract for enumStripControlChars (W-23336443): the z.preprocess
13
+ * wrapper that STRIPS Unicode control/format chars from a string before enum
14
+ * validation, so a poisoned value can never be reflected verbatim by the MCP
15
+ * SDK's upstream validation. End-to-end proof that it closes the actual SDK
16
+ * reflection channel lives in the sf-gql-* tool specs; here we pin the wrapper's
17
+ * behavior in isolation (accept-after-strip, clean rejection, pass-throughs) and
18
+ * document the discriminatedUnion caveat with an executable proof.
19
+ *
20
+ * Injected chars are `\u` escapes, not literals, so the source has no invisible
21
+ * bytes. Representative one-per-class: U+202E (bidi RLO), U+200B (ZWSP), U+007F
22
+ * (DEL) -- the exact trio the SDK's JSON.stringify leaves raw (it escapes only
23
+ * C0, U+0000-U+001F).
24
+ */
25
+ describe("schemas/fields — enumStripControlChars (W-23336443)", () => {
26
+ const MODE = enumStripControlChars(z.enum(["list_objects", "describe_object", "describe_field"]));
27
+
28
+ describe("accept-after-strip", () => {
29
+ it.each([
30
+ ["trailing bidi RLO", "describe_object\u{202e}", "describe_object"],
31
+ ["leading ZWSP", "\u{200b}list_objects", "list_objects"],
32
+ ["embedded DEL", "describe\x7f_field", "describe_field"],
33
+ ["interior ZWJ", "describe_ob\u{200d}ject", "describe_object"],
34
+ ])("%s strips to the valid member and PARSES to it", (_label, input, expected) => {
35
+ const result = MODE.safeParse(input);
36
+ expect(result.success).toBe(true);
37
+ if (result.success) expect(result.data).toBe(expected);
38
+ });
39
+
40
+ it("a clean value is unchanged", () => {
41
+ const result = MODE.safeParse("list_objects");
42
+ expect(result.success).toBe(true);
43
+ if (result.success) expect(result.data).toBe("list_objects");
44
+ });
45
+
46
+ it.each([
47
+ ["trailing line separator U+2028", "list_objects\u{2028}", "list_objects"],
48
+ ["leading paragraph separator U+2029", "\u{2029}describe_object", "describe_object"],
49
+ ])(
50
+ "%s is ALSO stripped (Zl/Zp, out of the Cc/Cf class but stripped on this path)",
51
+ (_label, input, expected) => {
52
+ // W-23336443 / F2: this rejection path reaches the host UPSTREAM of the
53
+ // envelope's own line-separator strip, so enumStripControlChars must
54
+ // strip U+2028/U+2029 itself (raw, they trip a Claude.AI 408). They are
55
+ // NOT Cc/Cf, so stripControlChars alone would miss them.
56
+ const result = MODE.safeParse(input);
57
+ expect(result.success).toBe(true);
58
+ if (result.success) expect(result.data).toBe(expected);
59
+ },
60
+ );
61
+
62
+ it("a genuinely-invalid value poisoned with U+2028/U+2029 rejects with neither separator raw", () => {
63
+ const result = MODE.safeParse("bo\u{2028}gus\u{2029}");
64
+ expect(result.success).toBe(false);
65
+ if (!result.success) {
66
+ const serialized = JSON.stringify(result.error.issues);
67
+ expect(serialized).not.toContain("\u{2028}");
68
+ expect(serialized).not.toContain("\u{2029}");
69
+ const received = (result.error.issues[0] as { received?: string }).received;
70
+ expect(received).toBe("bogus");
71
+ }
72
+ });
73
+ });
74
+
75
+ describe("clean rejection", () => {
76
+ it("a poisoned genuinely-invalid value rejects with NO raw control char in the message", () => {
77
+ // "bogus" + bidi + ZWSP + DEL. Strips to "bogus", which is not a member,
78
+ // so it rejects -- but zod reflects the STRIPPED value, so the specific
79
+ // injected code points must be absent from the issue.
80
+ const result = MODE.safeParse("bo\u{202e}gus\u{200b}\x7f");
81
+ expect(result.success).toBe(false);
82
+ if (!result.success) {
83
+ const serialized = JSON.stringify(result.error.issues);
84
+ // Assert the SPECIFIC injected code points are gone (a whole-class
85
+ // /[\p{Cc}\p{Cf}]/ check would false-positive on any structural
86
+ // whitespace zod/JSON introduce -- see control-chars.spec.ts note).
87
+ expect(serialized).not.toContain("\u{202e}");
88
+ expect(serialized).not.toContain("\u{200b}");
89
+ expect(serialized).not.toContain("\x7f");
90
+ // The reflected received value is the stripped token.
91
+ const received = (result.error.issues[0] as { received?: string }).received;
92
+ expect(received).toBe("bogus");
93
+ }
94
+ });
95
+
96
+ it("preserves the enum options in the issue (LLM still learns the allowed set)", () => {
97
+ const result = MODE.safeParse("bogus");
98
+ expect(result.success).toBe(false);
99
+ if (!result.success) {
100
+ const issue = result.error.issues[0] as { options?: string[] };
101
+ expect(issue.options).toEqual(["list_objects", "describe_object", "describe_field"]);
102
+ }
103
+ });
104
+ });
105
+
106
+ describe("pass-through of non-strings and optional/undefined", () => {
107
+ it("undefined passes through an optional wrapped enum (stays optional)", () => {
108
+ const OP = enumStripControlChars(z.enum(["query", "mutation", "aggregate"]).optional());
109
+ const result = OP.safeParse(undefined);
110
+ expect(result.success).toBe(true);
111
+ if (result.success) expect(result.data).toBeUndefined();
112
+ });
113
+
114
+ it("a non-string value is handed to the inner schema untouched (rejects as a type error)", () => {
115
+ const result = MODE.safeParse(42);
116
+ expect(result.success).toBe(false);
117
+ // The preprocess only strips strings; a number falls straight through
118
+ // to z.enum, which rejects it as an invalid_type / invalid_enum_value.
119
+ });
120
+ });
121
+
122
+ describe("published JSON-Schema shape is preserved (zod .describe passes through)", () => {
123
+ it("keeps the .describe() description on the wrapped enum", () => {
124
+ const described = enumStripControlChars(z.enum(["query", "mutation", "aggregate"])).describe(
125
+ "Operation root.",
126
+ );
127
+ expect(described.description).toBe("Operation root.");
128
+ });
129
+ });
130
+
131
+ describe("discriminatedUnion caveat (why the aggregate `function` enum is NOT wrapped)", () => {
132
+ // Executable proof that leaving discriminators un-wrapped is SAFE, and of
133
+ // the true reason (the WI's "breaks construction / narrow residual is
134
+ // reflected" rationale was imprecise for zod 3.25.76): a bad discriminator
135
+ // raises `invalid_union_discriminator`, whose issue lists only the EXPECTED
136
+ // options and does NOT echo the received value — so, unlike a plain
137
+ // `invalid_enum_value`, there is no verbatim-reflection channel to close.
138
+ it("a bad discriminator does NOT echo the received value (no reflection channel)", () => {
139
+ const du = z.discriminatedUnion("function", [
140
+ z.object({ function: z.enum(["count", "countDistinct"]), field: z.string().optional() }),
141
+ z.object({ function: z.enum(["sum", "avg", "min", "max"]), field: z.string() }),
142
+ ]);
143
+ // A control-char-poisoned discriminator: it never matches a branch, so
144
+ // discrimination fails BEFORE any per-branch enum check would run.
145
+ const result = du.safeParse({ function: "count\u{202e}\u{200b}\x7f", field: "Amount" });
146
+ expect(result.success).toBe(false);
147
+ if (!result.success) {
148
+ const issue = result.error.issues[0] as {
149
+ code: string;
150
+ options?: string[];
151
+ received?: unknown;
152
+ };
153
+ expect(issue.code).toBe("invalid_union_discriminator");
154
+ // Lists the expected set...
155
+ expect(issue.options).toEqual(["count", "countDistinct", "sum", "avg", "min", "max"]);
156
+ // ...and crucially carries NO `received` echo of the poisoned input.
157
+ const serialized = JSON.stringify(result.error.issues);
158
+ expect(serialized).not.toContain("\u{202e}");
159
+ expect(serialized).not.toContain("\u{200b}");
160
+ expect(serialized).not.toContain("\x7f");
161
+ }
162
+ });
163
+
164
+ it("a plain z.union member CAN be wrapped safely (the groupBy `function` case)", () => {
165
+ const grouped = z.union([
166
+ z.string(),
167
+ z.object({
168
+ field: z.string(),
169
+ function: enumStripControlChars(z.enum(["CALENDAR_MONTH", "CALENDAR_YEAR"])),
170
+ }),
171
+ ]);
172
+ const result = grouped.safeParse({
173
+ field: "CreatedDate",
174
+ function: "CALENDAR_MONTH\u{202e}",
175
+ });
176
+ expect(result.success).toBe(true);
177
+ if (result.success && typeof result.data === "object") {
178
+ expect((result.data as { function: string }).function).toBe("CALENDAR_MONTH");
179
+ }
180
+ });
181
+ });
182
+ });
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { z } from "zod";
8
+ import { stripControlChars, stripLineSeparators } from "../lib/control-chars.js";
8
9
  import { DOTTED_GRAPHQL_NAME_RE, GRAPHQL_NAME_RE } from "../lib/graphql-name.js";
9
10
 
10
11
  /**
@@ -40,6 +41,52 @@ const coerceJsonOnce = (v: unknown): unknown => {
40
41
  /** Wrap a schema so a JSON-stringified value is coerced before validation. */
41
42
  export const jsonCoercible = (schema: z.ZodTypeAny) => z.preprocess(coerceJsonArg, schema);
42
43
 
44
+ /**
45
+ * Wrap an enum (or any string schema) so Unicode control/format chars are
46
+ * STRIPPED from a string value before validation (W-23336443). Motivation: a
47
+ * `z.enum` rejection is reflected VERBATIM by the MCP SDK's input validation,
48
+ * which runs UPSTREAM of `runTool` — so the adapter's `neutralizeControlChars`
49
+ * never executes, and a poisoned value like `"describe_object‮"` reaches
50
+ * the host raw inside `received '…'`. `JSON.stringify` (which the SDK uses)
51
+ * escapes only C0, so DEL and the entire Cf class (bidi overrides, zero-width)
52
+ * survive. Stripping here closes that channel two ways: a control-char-poisoned
53
+ * but otherwise-valid value strips to the valid enum member (accepted, no
54
+ * message), and a genuinely-invalid value rejects with a message free of raw
55
+ * control chars.
56
+ *
57
+ * This upstream path also strips U+2028/U+2029 (via {@link stripLineSeparators}),
58
+ * matching what every envelope sink does in `tool-adapter.ts`: those separators
59
+ * are NOT Cc/Cf so `stripControlChars` correctly ignores them, but left raw in
60
+ * host-visible text they trip a Claude.AI 408 (MCP TS SDK #2155) — and this
61
+ * rejection message reaches the host BEFORE the envelope's own line-separator
62
+ * strip can run, so it must strip them itself.
63
+ *
64
+ * Why `z.preprocess` and not `.refine`: preprocess can TRANSFORM (strip) the
65
+ * value, and — verified against the SDK's zod-to-json-schema converter — the
66
+ * wrapper PRESERVES the published JSON-Schema `enum` and `description`, so the
67
+ * advertised allowed-value list the LLM sees is unchanged. `.refine` can only
68
+ * reject, not strip.
69
+ *
70
+ * CAVEAT: applying this to a `z.discriminatedUnion` discriminator member is
71
+ * pointless, though not harmful. It is NOT harmful because zod's discriminator-
72
+ * map builder recurses through a `z.preprocess` wrapper (`getDiscriminator`
73
+ * reads `ZodEffects.innerType()`), so construction does NOT throw and clean
74
+ * values still route correctly (verified against zod 3.25.76 on AGGREGATE_INPUT).
75
+ * It is POINTLESS because discrimination reads the RAW `ctx.data[discriminator]`
76
+ * to pick a branch BEFORE that branch's preprocess ever runs — so the strip
77
+ * cannot influence branch selection. A discriminator that matches no branch
78
+ * raises `invalid_union_discriminator`, whose issue lists only the EXPECTED
79
+ * options and does NOT echo the received value — so, unlike a plain
80
+ * `invalid_enum_value`, there is no verbatim-reflection channel there to close.
81
+ * The aggregate `function` discriminators are therefore left un-wrapped per
82
+ * W-23336443; only plain enums (invalid_enum_value DOES echo `received`) need it.
83
+ */
84
+ export const enumStripControlChars = <T extends z.ZodTypeAny>(schema: T) =>
85
+ z.preprocess(
86
+ (v) => (typeof v === "string" ? stripLineSeparators(stripControlChars(v)) : v),
87
+ schema,
88
+ );
89
+
43
90
  /** A string that looks like a JSON object/array literal — used so the advertised
44
91
  * schema accepts a stringified object/array; coerceJsonArg then parses it. A
45
92
  * `{`/`[`-prefixed but invalid-JSON string is accepted as a literal (rare; the
@@ -20,6 +20,7 @@ import { z } from "zod";
20
20
  import {
21
21
  childRelationshipSchema,
22
22
  dottedGraphqlName,
23
+ enumStripControlChars,
23
24
  graphqlName,
24
25
  intLiteralString,
25
26
  jsonCoercible,
@@ -139,11 +140,13 @@ export const DISCOVER_INPUT = z.object({
139
140
  .string()
140
141
  .regex(DISCOVER_ORG_ALIAS_RE, "org must match /^[A-Za-z0-9_-]{1,80}$/")
141
142
  .describe("Org alias resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."),
142
- mode: z
143
- .enum(["list_objects", "describe_object", "describe_field"])
144
- .describe(
145
- 'Discovery mode. "list_objects" enumerates queryable SObjects; "describe_object" and "describe_field" return ObjectInfo metadata.',
146
- ),
143
+ // enumStripControlChars: a bad `mode` is reflected verbatim by the SDK's
144
+ // upstream validation (W-23336443) strip Cc/Cf before it can reach the host.
145
+ mode: enumStripControlChars(
146
+ z.enum(["list_objects", "describe_object", "describe_field"]),
147
+ ).describe(
148
+ 'Discovery mode. "list_objects" enumerates queryable SObjects; "describe_object" and "describe_field" return ObjectInfo metadata.',
149
+ ),
147
150
  object: z
148
151
  .string()
149
152
  .regex(DISCOVER_SOBJECT_NAME_RE, "object must match /^[A-Za-z][A-Za-z0-9_]{0,79}$/")
@@ -169,6 +172,17 @@ const aliasField = z
169
172
  .optional()
170
173
  .describe("GraphQL alias for this aggregation's result key.");
171
174
 
175
+ // NOTE (W-23336443): the `function` enums below are z.discriminatedUnion
176
+ // DISCRIMINATORS — they are intentionally NOT wrapped in enumStripControlChars.
177
+ // Wrapping would be pointless (not harmful): discrimination reads the RAW
178
+ // `ctx.data.function` to pick a branch BEFORE that branch's preprocess runs, so
179
+ // a strip could never change branch selection. And there is nothing to close: a
180
+ // discriminator that matches no branch raises `invalid_union_discriminator`,
181
+ // whose issue lists only the EXPECTED options and does NOT echo the received
182
+ // value (verified, zod 3.25.76) — unlike a plain enum's `invalid_enum_value`,
183
+ // which reflects `received` verbatim. The three plain enums that DO echo their
184
+ // input (mode/operation/groupBy function) are the ones wrapped. See
185
+ // enumStripControlChars in ./fields for the full mechanism.
172
186
  const aggregationSchema = z.discriminatedUnion("function", [
173
187
  z.object({
174
188
  function: z.enum(["count", "countDistinct"]),
@@ -186,9 +200,11 @@ const groupByElementSchema = z.union([
186
200
  graphqlName("SObject field API name to group by (flat, non-dotted)."),
187
201
  z.object({
188
202
  field: graphqlName("SObject field API name (DateTime/Date field)."),
189
- function: z
190
- .enum(GROUP_BY_FUNCTIONS)
191
- .describe("Date bucketing function from UIAPI GroupByFunction enum."),
203
+ // Plain z.union member (NOT a discriminatedUnion discriminator), so the
204
+ // enumStripControlChars wrapper is safe here (W-23336443).
205
+ function: enumStripControlChars(z.enum(GROUP_BY_FUNCTIONS)).describe(
206
+ "Date bucketing function from UIAPI GroupByFunction enum.",
207
+ ),
192
208
  }),
193
209
  ]);
194
210
 
@@ -254,12 +270,12 @@ export const RAW_INPUT = z.object({
254
270
  'Each command is tokenized on spaces and a value MUST NOT contain a space — quoting does not help once a token has started (key=\'a b\' still splits). A filter value that contains a space (e.g. "New York", "In Progress") cannot be expressed via set in v1; use sf_gql_list with a JSON filter, or pass the value through a variable bound with var.\n' +
255
271
  "Fails fast: a bad command aborts the whole call. Other CLI verbs (cd, drop, alias, optional, unset) are NOT supported in v1 — and the `optional` verb is unnecessary here: like every declarative tool, sf_gql_raw emits all selected record fields with the @optional directive automatically, so a field the running user lacks FLS for is omitted gracefully instead of failing the whole query.",
256
272
  ),
257
- operation: z
258
- .enum(["query", "mutation", "aggregate"])
259
- .optional()
260
- .describe(
261
- 'Operation root. "query" (default) → uiapi.query; "mutation" → mutation root; "aggregate" → uiapi.aggregate.',
262
- ),
273
+ // enumStripControlChars strips Cc/Cf from a bad `operation` before the SDK
274
+ // reflects it verbatim in a rejection message (W-23336443); the wrapper
275
+ // preserves the published enum + optional flag (undefined passes through).
276
+ operation: enumStripControlChars(z.enum(["query", "mutation", "aggregate"]).optional()).describe(
277
+ 'Operation root. "query" (default) → uiapi.query; "mutation" → mutation root; "aggregate" → uiapi.aggregate.',
278
+ ),
263
279
  typeName: graphqlName(
264
280
  "Override the GraphQL operation name. Defaults to Raw<Operation>.",
265
281
  ).optional(),
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import os from "node:os";
8
+ import { CONTROL_CHAR_RE, stripLineSeparators } from "../lib/control-chars.js";
8
9
  import {
9
10
  AuthError,
10
11
  classifyCause,
@@ -82,26 +83,15 @@ export const PATH_MARKERS = {
82
83
  redacted: "<path>",
83
84
  } as const;
84
85
 
85
- // Unicode line separators (U+2028/U+2029) are legal in JSON strings but are NOT
86
- // escaped by JSON.stringify; left raw in `content.text` they trip a Claude.AI 408
87
- // timeout (MCP TS SDK #2155). Strip them from every host-visible envelope.
88
- const LINE_SEPARATOR_RE = /[\u2028\u2029]/g;
89
- function stripLineSeparators(s: string): string {
90
- return s.replace(LINE_SEPARATOR_RE, "");
91
- }
92
-
93
- // Control (Cc: C0/C1/DEL) and format (Cf) characters that, left raw in reflected
94
- // error text, let caller-supplied input forge structure in the host-visible MCP
95
- // envelope (W-23148363, OWASP LLM01): newlines/CR fabricate "SYSTEM:"-style lines,
96
- // ESC (\x1b) opens ANSI/SGR sequences, NEL (U+0085) and the C1 8-bit CSI (\x9b) are
97
- // alternate line/escape introducers, and the Cf set (bidi overrides/marks/isolates
98
- // incl U+061C, zero-width joiners/spaces, word joiner, BOM, invisible-math operators,
99
- // soft hyphen, and the U+E0000-E007F "tag" smuggling block) reorders or hides text.
100
- // Matched by Unicode property class so the set tracks future Cc/Cf additions instead
101
- // of a hand-enumerated range that drifts (an earlier explicit range silently missed
102
- // U+061C / ZWNJ / ZWJ / tag chars -- all Cf). The ERROR envelope interpolates the
103
- // message raw (`${category}: ${text}`), so every one of these reaches the host
104
- // unescaped -- which is the vector this neutralizer closes.
86
+ // `stripLineSeparators` (imported) removes U+2028/U+2029 from every host-visible
87
+ // envelope below (they trip a Claude.AI 408 \u2014 see `lib/control-chars.ts`). Shared
88
+ // with the enum-rejection path (W-23336443) so the two sinks cannot drift.
89
+ //
90
+ // The Cc/Cf class this escaper acts on is the shared CONTROL_CHAR_RE (see
91
+ // `lib/control-chars.ts` for the full rationale — why bidi/zero-width/tag chars
92
+ // are dangerous and why a Unicode property class is used over a hand-enumerated
93
+ // range). Imported, not re-declared, so this ERROR-envelope escaper and the
94
+ // enum-rejection stripper (W-23336443) cannot drift apart.
105
95
  // SUCCESS-ENVELOPE SCOPE (W-23148363): the success path (runTool below) emits
106
96
  // JSON.stringify(output), which escapes ONLY the C0 range (U+0000-U+001F). DEL
107
97
  // (U+007F) and the ENTIRE Cf class (bidi overrides, zero-width, BOM, tag block)
@@ -115,9 +105,7 @@ function stripLineSeparators(s: string): string {
115
105
  // Value-level neutralization is tracked as a follow-up WI -- note it must emit
116
106
  // GraphQL-valid `\uXXXX` (never `\xNN`) since the success `query` is a live
117
107
  // GraphQL document, so the whole-envelope neutralizer here cannot be reused as-is.
118
- // U+2028/U+2029 are deliberately absent (they are Zl/Zp, not Cc/Cf): stripLineSeparators
119
- // removes them entirely, and runs first.
120
- const NEUTRALIZE_RE = /[\p{Cc}\p{Cf}]/gu;
108
+ // CONTROL_CHAR_RE carries the `g` + `u` flags, required by the `.replace` below.
121
109
 
122
110
  /**
123
111
  * Escape (not strip) control / format characters in host-visible error text so
@@ -125,11 +113,11 @@ const NEUTRALIZE_RE = /[\p{Cc}\p{Cf}]/gu;
125
113
  * (W-23148363). Escaping mirrors the SUCCESS envelope's JSON.stringify semantics
126
114
  * (`\n` -> `\\x0a`, U+202E -> `\\u202e`) -- the byte stays visible for debugging but
127
115
  * inert. Ordinary Unicode (accented names, CJK labels) is untouched. Astral code
128
- * points (e.g. the U+E0000-E007F tag block) render as `\\u{...}`; `NEUTRALIZE_RE`
116
+ * points (e.g. the U+E0000-E007F tag block) render as `\\u{...}`; `CONTROL_CHAR_RE`
129
117
  * carries the `u` flag so they match (and escape) as a single code point.
130
118
  */
131
119
  export function neutralizeControlChars(s: string): string {
132
- return s.replace(NEUTRALIZE_RE, (c) => {
120
+ return s.replace(CONTROL_CHAR_RE, (c) => {
133
121
  const cp = c.codePointAt(0) ?? 0;
134
122
  if (cp <= 0xff) return `\\x${cp.toString(16).padStart(2, "0")}`;
135
123
  if (cp <= 0xffff) return `\\u${cp.toString(16).padStart(4, "0")}`;