@salesforce/graphiti 11.35.1 → 11.35.3

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.
@@ -7,6 +7,7 @@
7
7
  import { type z } from "zod";
8
8
  import { AuthError } from "../../lib/errors.js";
9
9
  import { SchemaRefreshError } from "../../lib/prime-schema.js";
10
+ import { sanitizePaths } from "../../schemas/tool-adapter.js";
10
11
 
11
12
  /**
12
13
  * Shared adapter for the `sf-gql-*` CLI mirror commands. Each command reads a
@@ -138,24 +139,33 @@ export async function runMirror<T>(
138
139
  const output = await build(result.data);
139
140
  console.log(JSON.stringify(output));
140
141
  } catch (err) {
141
- const message = err instanceof Error ? err.message : String(err);
142
+ const rawMessage = err instanceof Error ? err.message : String(err);
143
+ // Classify on the RAW message (the heuristic keys off auth/schema wording;
144
+ // sanitizePaths would relativize a "~/.sf" hint to a marker and defeat it).
142
145
  // Typed signals win; otherwise fall back to the best-effort message regex.
143
146
  // AuthError is checked first because its message (W-23335328) reads "Schema
144
147
  // priming failed … expired or unauthorized. Re-authenticate …" — that
145
148
  // "priming" makes classifyErrorMessage return SCHEMA_PRIME_FAILED, so an
146
149
  // untyped fallthrough would misclassify a 401/403 introspection failure as a
147
- // schema problem (the exact Schema-vs-Auth confusion this WI fixes).
150
+ // schema problem (the exact Schema-vs-Auth confusion W-23335328 fixes).
148
151
  const code: MirrorErrorCode =
149
152
  err instanceof AuthError
150
153
  ? "AUTH_FAILED"
151
154
  : err instanceof SchemaRefreshError
152
155
  ? "SCHEMA_PRIME_FAILED"
153
- : classifyErrorMessage(message);
156
+ : classifyErrorMessage(rawMessage);
157
+ // W-23336442 (N2): emit the SANITIZED message, never the raw one. sanitizePaths
158
+ // redacts absolute filesystem paths (home dir, repo checkout, schema cache —
159
+ // W-22697673) AND neutralizes Cc/Cf control chars (bidi/zero-width/tag chars
160
+ // that JSON.stringify leaves raw), so a caller- or filesystem-derived error
161
+ // string can neither disclose local layout nor smuggle control chars to the host.
162
+ const message = sanitizePaths(rawMessage);
154
163
  // The stack is omitted by default so the envelope never leaks internals;
155
- // GRAPHITI_DEBUG=1 opts into attaching it under `details` for debugging.
164
+ // GRAPHITI_DEBUG=1 opts into attaching it under `details` and even then it is
165
+ // run through the same sanitizer so the opt-in debug stack cannot leak paths.
156
166
  const details =
157
167
  process.env.GRAPHITI_DEBUG === "1" && err instanceof Error && err.stack
158
- ? { stack: err.stack }
168
+ ? { stack: sanitizePaths(err.stack) }
159
169
  : undefined;
160
170
  emitError(code, message, details);
161
171
  }
@@ -429,4 +429,166 @@ describe("intent/build-discover", () => {
429
429
  ).rejects.toThrow(/not found on "Account"/);
430
430
  });
431
431
  });
432
+
433
+ // W-23336442: free-text org metadata (field label, picklist value, schema
434
+ // description) is reflected verbatim through the SUCCESS envelope, whose
435
+ // JSON.stringify escapes only C0 (U+0000-U+001F). DEL (U+007F) and the entire
436
+ // Cf class (bidi overrides, zero-width, ...) survive raw, so buildDiscover must
437
+ // neutralize them at the projection sites. `neutralizeControlChars` escapes to
438
+ // a visible `\xNN` (cp<=0xff) or `\uNNNN` literal; ordinary Unicode is untouched.
439
+ describe("free-text metadata neutralization (W-23336442)", () => {
440
+ // Raw dangerous code points (escapes in SOURCE, raw at runtime): U+202E
441
+ // RIGHT-TO-LEFT OVERRIDE (Cf), U+200B ZERO WIDTH SPACE (Cf), U+007F DELETE (Cc).
442
+ const RLO = "\u{202e}";
443
+ const ZWSP = "\u{200b}";
444
+ const DEL = "\x7f";
445
+ // Their neutralized (visible, inert) forms.
446
+ const ESC_RLO = "\\u202e";
447
+ const ESC_ZWSP = "\\u200b";
448
+ const ESC_DEL = "\\x7f";
449
+
450
+ const TAINTED_INFO: ObjectInfoResult = {
451
+ ...ACCOUNT_INFO,
452
+ fields: [
453
+ makeField({
454
+ apiName: "Industry",
455
+ label: `Ind${RLO}ustry${ZWSP} café 日本語${DEL}`,
456
+ dataType: "PICKLIST",
457
+ createable: true,
458
+ updateable: true,
459
+ }),
460
+ ],
461
+ picklists: [
462
+ {
463
+ apiName: "Industry",
464
+ label: "Industry",
465
+ required: false,
466
+ values: [
467
+ { value: `Tech${RLO}nology${DEL}`, label: "Technology" },
468
+ { value: `Fin${ZWSP}ance café`, label: "Finance" },
469
+ ],
470
+ },
471
+ ],
472
+ };
473
+
474
+ function taintedDeps(): DiscoverDeps {
475
+ return {
476
+ primeDeps: makePrimeDeps(),
477
+ getOrgAuth: async () => ({
478
+ alias: ORG,
479
+ username: "u",
480
+ instanceUrl: ORG_URL,
481
+ accessToken: "t",
482
+ orgId: "00D",
483
+ }),
484
+ getObjectInfo: async () => TAINTED_INFO,
485
+ };
486
+ }
487
+
488
+ it("escapes Cc/Cf in a field label but preserves ordinary Unicode (describe_field)", async () => {
489
+ const out = await buildDiscover(
490
+ { org: ORG, mode: "describe_field", object: "Account", field: "Industry" },
491
+ taintedDeps(),
492
+ );
493
+ if (out.mode !== "describe_field") return;
494
+
495
+ // Escaped: the raw code points are ABSENT and appear as visible escapes.
496
+ expect(out.field.label).toBe(`Ind${ESC_RLO}ustry${ESC_ZWSP} café 日本語${ESC_DEL}`);
497
+ expect(out.field.label).not.toContain(RLO);
498
+ expect(out.field.label).not.toContain(ZWSP);
499
+ expect(out.field.label).not.toContain(DEL);
500
+ // Ordinary Unicode survives verbatim.
501
+ expect(out.field.label).toContain("café");
502
+ expect(out.field.label).toContain("日本語");
503
+ });
504
+
505
+ it("escapes Cc/Cf in picklist values but preserves ordinary Unicode (describe_field)", async () => {
506
+ const out = await buildDiscover(
507
+ { org: ORG, mode: "describe_field", object: "Account", field: "Industry" },
508
+ taintedDeps(),
509
+ );
510
+ if (out.mode !== "describe_field") return;
511
+
512
+ expect(out.field.picklistValues).toEqual([
513
+ `Tech${ESC_RLO}nology${ESC_DEL}`,
514
+ `Fin${ESC_ZWSP}ance café`,
515
+ ]);
516
+ const joined = (out.field.picklistValues ?? []).join("");
517
+ expect(joined).not.toContain(RLO);
518
+ expect(joined).not.toContain(ZWSP);
519
+ expect(joined).not.toContain(DEL);
520
+ expect(joined).toContain("café");
521
+ });
522
+
523
+ it("escapes Cc/Cf in field labels reached via describe_object", async () => {
524
+ const out = await buildDiscover(
525
+ { org: ORG, mode: "describe_object", object: "Account" },
526
+ taintedDeps(),
527
+ );
528
+ if (out.mode !== "describe_object") return;
529
+
530
+ const industry = out.object.fields.find((f) => f.name === "Industry");
531
+ expect(industry?.label).toBe(`Ind${ESC_RLO}ustry${ESC_ZWSP} café 日本語${ESC_DEL}`);
532
+ expect(industry?.label).not.toContain(RLO);
533
+ expect(industry?.label).not.toContain(DEL);
534
+ expect(industry?.picklistValues).toEqual([
535
+ `Tech${ESC_RLO}nology${ESC_DEL}`,
536
+ `Fin${ESC_ZWSP}ance café`,
537
+ ]);
538
+ });
539
+
540
+ it("escapes Cc/Cf in the list_objects label (schema description)", async () => {
541
+ const ALIAS = "test-discover-taint";
542
+ const URL = "https://test-discover-taint.my.salesforce.com";
543
+ // Descriptions carry the raw code points at runtime (source uses escapes).
544
+ // U+202E/U+200B/U+007F are all valid GraphQL SourceCharacters (>= U+0020).
545
+ const taintSchema = buildSchema(`
546
+ type Query { uiapi: UIAPI! }
547
+ type UIAPI { query: RecordQuery! }
548
+ type RecordQuery {
549
+ "Cust${RLO}omer${ZWSP} café 日本語${DEL}"
550
+ Account(first: Int): AccountConnection!
551
+ }
552
+ type AccountConnection { edges: [AccountEdge!]! }
553
+ type AccountEdge { node: Account! }
554
+ type Account { Id: ID! }
555
+ `);
556
+ primeSchemaCache(ALIAS, taintSchema);
557
+ primeSchemaCache(URL, taintSchema);
558
+
559
+ const deps: DiscoverDeps = {
560
+ primeDeps: {
561
+ getOrgAuth: async () => ({
562
+ alias: ALIAS,
563
+ username: "u",
564
+ instanceUrl: URL,
565
+ accessToken: "t",
566
+ orgId: "00D",
567
+ }),
568
+ downloadSchema: async (auth) => {
569
+ const cacheKey = schemaCacheKeyForInstanceUrl(auth.instanceUrl);
570
+ const filePath = path.join(schemaDir(), `${cacheKey}.json`);
571
+ atomicWriteJson(filePath, { data: introspectionFromSchema(taintSchema) });
572
+ return {
573
+ cacheKey,
574
+ instanceUrl: auth.instanceUrl,
575
+ typeCount: 0,
576
+ downloadedAt: new Date().toISOString(),
577
+ filePath,
578
+ };
579
+ },
580
+ },
581
+ };
582
+
583
+ const out = await buildDiscover({ org: ALIAS, mode: "list_objects" }, deps);
584
+ if (out.mode !== "list_objects") return;
585
+ const account = out.objects.find((o) => o.name === "Account");
586
+ expect(account?.label).toBe(`Cust${ESC_RLO}omer${ESC_ZWSP} café 日本語${ESC_DEL}`);
587
+ expect(account?.label).not.toContain(RLO);
588
+ expect(account?.label).not.toContain(ZWSP);
589
+ expect(account?.label).not.toContain(DEL);
590
+ expect(account?.label).toContain("café");
591
+ expect(account?.label).toContain("日本語");
592
+ });
593
+ });
432
594
  });
@@ -21,6 +21,7 @@ import {
21
21
  } from "../lib/object-info.js";
22
22
  import { type PrimeDeps } from "../lib/prime-schema.js";
23
23
  import { resolvePath } from "../lib/walker.js";
24
+ import { neutralizeControlChars } from "../schemas/tool-adapter.js";
24
25
 
25
26
  export interface DiscoverDeps {
26
27
  primeDeps?: PrimeDeps;
@@ -124,8 +125,12 @@ function listQueryableObjects(
124
125
  result = walker.fields
125
126
  .filter((f) => /Connection$/.test(f.typeName.replace(/[![\]]/g, "")))
126
127
  .map((f) => ({
128
+ // `name` is a GraphQL field name (charset-guarded identifier) — left
129
+ // as-is. W-23336442: `label` is free-text org schema description
130
+ // reflected verbatim into the success envelope, so neutralize its
131
+ // Cc/Cf (bidi/zero-width/DEL) before it reaches the host.
127
132
  name: f.name,
128
- ...(f.description ? { label: f.description } : {}),
133
+ ...(f.description ? { label: neutralizeControlChars(f.description) } : {}),
129
134
  }));
130
135
  } catch {
131
136
  return [];
@@ -143,12 +148,20 @@ function toFieldDescription(field: FieldMetadata, info: ObjectInfoResult): Field
143
148
  const picklist = info.picklists.find((p) => p.apiName === field.apiName);
144
149
  // `parseObjectInfoResponse` already drops null-valued entries, but the
145
150
  // PicklistValue type still allows null — filter narrows to string[].
151
+ // W-23336442: picklist `value` is free-text org metadata reflected verbatim
152
+ // into the success envelope — neutralize Cc/Cf before it reaches the host.
146
153
  const picklistValues =
147
- picklist?.values.map((v) => v.value).filter((v): v is string => v !== null) ?? [];
154
+ picklist?.values
155
+ .map((v) => v.value)
156
+ .filter((v): v is string => v !== null)
157
+ .map(neutralizeControlChars) ?? [];
148
158
 
149
159
  return {
160
+ // W-23336442: `name`/`type` are identifier/system-enum (already charset-
161
+ // safe) so pass through; `label` is free-text org metadata, so neutralize
162
+ // its Cc/Cf (bidi/zero-width/DEL) on this success-envelope reflection path.
150
163
  name: field.apiName,
151
- label: field.label ?? field.apiName,
164
+ label: neutralizeControlChars(field.label ?? field.apiName),
152
165
  type: field.dataType ?? "UNKNOWN",
153
166
  filterable: field.filterable,
154
167
  sortable: field.sortable,
@@ -4,9 +4,11 @@
4
4
  * For full license text, see the LICENSE.txt file
5
5
  */
6
6
 
7
+ import { parse } from "graphql";
7
8
  import { describe, expect, it } from "vitest";
8
9
  import {
9
10
  CONTROL_CHAR_RE,
11
+ escapeControlCharsGraphQL,
10
12
  LINE_SEPARATOR_RE,
11
13
  stripControlChars,
12
14
  stripLineSeparators,
@@ -117,6 +119,72 @@ describe("lib/control-chars", () => {
117
119
  });
118
120
  });
119
121
 
122
+ // escapeControlCharsGraphQL is the THIRD disposition of the shared Cc/Cf class
123
+ // (W-23336442): unlike stripControlChars (delete) or neutralizeControlChars
124
+ // (\xNN / \u{...} for a plain-JSON envelope), it emits ONLY GraphQL-valid
125
+ // \uXXXX escapes because its output lands inside a LIVE GraphQL string literal
126
+ // -- \xNN would make the query un-parseable and \u{...} isn't universally
127
+ // accepted, so astral chars become a surrogate PAIR.
128
+ describe("escapeControlCharsGraphQL", () => {
129
+ // Expected \uXXXX (or surrogate-pair) rendering for each representative.
130
+ const ESCAPED: [string, string, string][] = [
131
+ ["C0 LF", "\n", "\\u000a"],
132
+ ["C0 NUL", "\x00", "\\u0000"],
133
+ ["C0 ESC", "\x1b", "\\u001b"],
134
+ ["DEL (survives JSON.stringify)", "\x7f", "\\u007f"],
135
+ ["C1 CSI", "\x9b", "\\u009b"],
136
+ ["NEL", "\u{85}", "\\u0085"],
137
+ ["ZWSP (BMP Cf)", "\u{200b}", "\\u200b"],
138
+ ["bidi RLO (BMP Cf)", "\u{202e}", "\\u202e"],
139
+ ["BOM/ZWNBSP", "\u{feff}", "\\ufeff"],
140
+ // Astral tag char -> UTF-16 surrogate pair (U+E0001 = D800+... / DC00+...).
141
+ ["tag block U+E0001 (astral)", "\u{e0001}", "\\udb40\\udc01"],
142
+ ];
143
+
144
+ it.each(ESCAPED)("escapes %s to a lower-case \\uXXXX escape", (_label, ch, esc) => {
145
+ expect(escapeControlCharsGraphQL(`a${ch}b`)).toBe(`a${esc}b`);
146
+ });
147
+
148
+ it("emits the astral tag char as a surrogate pair, never \\u{...}", () => {
149
+ const out = escapeControlCharsGraphQL("\u{e0001}");
150
+ expect(out).toBe("\\udb40\\udc01");
151
+ expect(out).not.toContain("\\u{");
152
+ });
153
+
154
+ it("leaves ordinary Unicode (accents, CJK, emoji) untouched", () => {
155
+ const ok = "plain ASCII café 日本語 naïve \u{1f600}";
156
+ expect(escapeControlCharsGraphQL(ok)).toBe(ok);
157
+ });
158
+
159
+ it("leaves U+2028/U+2029 untouched (Zl/Zp are out of the Cc/Cf class)", () => {
160
+ expect(escapeControlCharsGraphQL("a\u{2028}b\u{2029}c")).toBe("a\u{2028}b\u{2029}c");
161
+ });
162
+
163
+ it("escapes every occurrence, not just the first", () => {
164
+ expect(escapeControlCharsGraphQL("a\u{202e}b\u{200b}c\x7fd")).toBe(
165
+ "a\\u202eb\\u200bc\\u007fd",
166
+ );
167
+ });
168
+
169
+ it("output contains no raw Cc/Cf code point and no GraphQL-invalid \\xNN / \\u{...}", () => {
170
+ const poison = ESCAPED.map(([, ch]) => ch).join("x");
171
+ const out = escapeControlCharsGraphQL(poison);
172
+ expect(out).not.toMatch(/[\p{Cc}\p{Cf}]/u);
173
+ // GraphQL rejects \xNN entirely and not every parser accepts \u{...}.
174
+ expect(out).not.toContain("\\x");
175
+ expect(out).not.toContain("\\u{");
176
+ });
177
+
178
+ it("produces a GraphQL-parseable string literal for a poisoned value (round-trip)", () => {
179
+ // Wrap the escaped output in quotes: it must be a valid GraphQL document.
180
+ const escaped = escapeControlCharsGraphQL("before\u{202e}\u{200b}\x7f\u{e0001}after");
181
+ const query = `query { field(arg: "${escaped}") }`;
182
+ expect(() => parse(query)).not.toThrow();
183
+ // The specific dangerous code points are absent from the raw query source.
184
+ expect(query).not.toMatch(/[\u{202e}\u{200b}\u{7f}\u{e0001}]/u);
185
+ });
186
+ });
187
+
120
188
  // U+2028/U+2029 are Zl/Zp -- NOT part of CONTROL_CHAR_RE (asserted above) --
121
189
  // so they get their own primitive. Every host-visible sink strips them
122
190
  // separately because raw, they trip a Claude.AI 408 (MCP TS SDK #2155): the
@@ -548,4 +548,132 @@ describe("query-builder", () => {
548
548
  }
549
549
  });
550
550
  });
551
+
552
+ // W-23336442: caller-supplied string VALUES (sf_gql_list scope/filter/orderBy)
553
+ // reach the emitted GraphQL literal via formatArgValue / valueToGraphQL, which
554
+ // use JSON.stringify. JSON.stringify escapes only the C0 range (U+0000-U+001F)
555
+ // and leaves DEL (U+007F) plus the entire Cf class (bidi overrides, zero-width,
556
+ // tag block) RAW inside the quotes. Since the query is a LIVE GraphQL document
557
+ // reflected to the host, those survivors are post-escaped to GraphQL-valid
558
+ // \uXXXX. CRITICAL invariants: (1) the specific poisoning code points are ABSENT
559
+ // from the emitted source (present only as \uXXXX), and (2) graphql.parse() of
560
+ // the emitted query still succeeds (\uXXXX is spec-valid; \xNN would not be).
561
+ describe("control-char value escaping (W-23336442)", () => {
562
+ // U+202E (bidi RLO), U+200B (ZWSP), U+007F (DEL): all survive JSON.stringify.
563
+ const POISON = "a\u{202e}b\u{200b}c\u{7f}d";
564
+ const RAW_CODE_POINTS = /[\u{202e}\u{200b}\u{7f}]/u;
565
+
566
+ it("escapes DEL/Cf in a top-level string arg value; the query still parses", () => {
567
+ const session = makeSession();
568
+ session.navigationPath = ["query", "accounts"];
569
+ // A scalar arg value (like a scope token) carrying poisoned bytes.
570
+ setArg(session, ["accounts"], "first", POISON);
571
+ session.navigationPath = ["query", "accounts", "edges", "node"];
572
+ selectLeaf(session, ["accounts", "edges", "node", "name"]);
573
+
574
+ const query = renderQuery(session);
575
+ // The dangerous code points appear ONLY as \uXXXX, never raw.
576
+ expect(query).not.toMatch(RAW_CODE_POINTS);
577
+ expect(query).toContain("\\u202e");
578
+ expect(query).toContain("\\u200b");
579
+ expect(query).toContain("\\u007f");
580
+ // Live-document round-trip: the escaped literal must still parse.
581
+ expect(() => parse(query)).not.toThrow();
582
+ });
583
+
584
+ it("escapes DEL/Cf in a well-formed quoted string-literal arg value (the passthrough branch); the query still parses", () => {
585
+ const session = makeSession();
586
+ session.navigationPath = ["query", "accounts"];
587
+ // The value is ITSELF a well-formed quoted JSON string literal carrying the
588
+ // poisoned bytes, so formatArgValue takes its quoted-passthrough branch
589
+ // (query-builder.ts ~L291) — the third W-23336442 escape site, distinct from
590
+ // the default JSON.stringify branch and the valueToGraphQL path. JSON.parse
591
+ // accepts the raw DEL/Cf (legal unescaped inside a JSON string), so WITHOUT
592
+ // the post-escape the raw bytes would reflect verbatim into the live query.
593
+ setArg(session, ["accounts"], "first", JSON.stringify(POISON));
594
+ session.navigationPath = ["query", "accounts", "edges", "node"];
595
+ selectLeaf(session, ["accounts", "edges", "node", "name"]);
596
+
597
+ const query = renderQuery(session);
598
+ expect(query).not.toMatch(RAW_CODE_POINTS);
599
+ expect(query).toContain("\\u202e");
600
+ expect(query).toContain("\\u200b");
601
+ expect(query).toContain("\\u007f");
602
+ expect(() => parse(query)).not.toThrow();
603
+ });
604
+
605
+ it("escapes DEL/Cf inside a nested filter-object string value (the valueToGraphQL path)", () => {
606
+ const session = makeSession();
607
+ session.navigationPath = ["query", "accounts"];
608
+ // Mirrors buildList: JSON.stringify(spec.filter) stored as `where`, then
609
+ // rendered via jsonToGraphQL -> valueToGraphQL. The poisoned bytes live in
610
+ // a nested input-object string value.
611
+ const filter = { name: { like: POISON } };
612
+ deepSetArg(session, ["accounts"], "where", [], JSON.stringify(filter));
613
+ session.navigationPath = ["query", "accounts", "edges", "node"];
614
+ selectLeaf(session, ["accounts", "edges", "node", "name"]);
615
+
616
+ const query = renderQuery(session);
617
+ expect(query).not.toMatch(RAW_CODE_POINTS);
618
+ expect(query).toContain("\\u202e");
619
+ expect(query).toContain("\\u200b");
620
+ expect(query).toContain("\\u007f");
621
+ expect(() => parse(query)).not.toThrow();
622
+ });
623
+
624
+ it("escapes an astral tag char (U+E0001) as a GraphQL-valid surrogate pair, never \\u{...}", () => {
625
+ const session = makeSession();
626
+ session.navigationPath = ["query", "accounts"];
627
+ setArg(session, ["accounts"], "first", "x\u{e0001}y");
628
+ session.navigationPath = ["query", "accounts", "edges", "node"];
629
+ selectLeaf(session, ["accounts", "edges", "node", "name"]);
630
+
631
+ const query = renderQuery(session);
632
+ expect(query).not.toMatch(/\u{e0001}/u); // no raw astral tag char
633
+ expect(query).not.toContain("\\u{"); // never the variable-width form
634
+ expect(query).toContain("\\udb40\\udc01"); // the surrogate pair
635
+ expect(() => parse(query)).not.toThrow();
636
+ });
637
+
638
+ it("does not disturb $var, numeric, enum, or boolean value paths", () => {
639
+ const session = makeSession();
640
+ addVariable(session, "$lim", "Int");
641
+ session.navigationPath = ["query", "accounts"];
642
+ // $var placeholder — emitted bare, not quoted or escaped.
643
+ setArg(session, ["accounts"], "first", "$lim");
644
+ session.navigationPath = ["query", "accounts", "edges", "node"];
645
+ selectLeaf(session, ["accounts", "edges", "node", "name"]);
646
+
647
+ const query = renderQuery(session);
648
+ expect(query).toMatch(/first: \$lim\b/);
649
+ expect(query).not.toContain('"$lim"');
650
+ expect(() => parse(query)).not.toThrow();
651
+
652
+ // Numeric / boolean / enum defaults stay bare (unquoted, unescaped).
653
+ function withDefault(type: string, value: string): string {
654
+ const s = makeSession();
655
+ addVariable(s, "d", type, value);
656
+ s.navigationPath = ["query", "accounts", "edges", "node"];
657
+ selectLeaf(s, ["accounts", "edges", "node", "name"]);
658
+ return renderQuery(s);
659
+ }
660
+ expect(withDefault("Int", "42")).toContain("$d: Int = 42");
661
+ expect(withDefault("Boolean", "true")).toContain("$d: Boolean = true");
662
+ expect(withDefault("SortOrder", "DESC")).toContain("$d: SortOrder = DESC");
663
+ });
664
+
665
+ it("does not over-block ordinary Unicode string values (accents, CJK, emoji)", () => {
666
+ const session = makeSession();
667
+ session.navigationPath = ["query", "accounts"];
668
+ const ok = "café 日本語 \u{1f600}";
669
+ setArg(session, ["accounts"], "first", ok);
670
+ session.navigationPath = ["query", "accounts", "edges", "node"];
671
+ selectLeaf(session, ["accounts", "edges", "node", "name"]);
672
+
673
+ const query = renderQuery(session);
674
+ // Ordinary Unicode passes through verbatim inside the literal.
675
+ expect(query).toContain(`first: ${JSON.stringify(ok)}`);
676
+ expect(() => parse(query)).not.toThrow();
677
+ });
678
+ });
551
679
  });
@@ -47,6 +47,44 @@ export function stripControlChars(s: string): string {
47
47
  return s.replace(CONTROL_CHAR_RE, "");
48
48
  }
49
49
 
50
+ /**
51
+ * Escape every {@link CONTROL_CHAR_RE} code point to a GraphQL-valid `\uXXXX`
52
+ * escape, for reflection into a LIVE GraphQL DOCUMENT — the value-emission sites
53
+ * in `lib/query-builder.ts` (`sf_gql_list` scope/filter/orderBy values, and any
54
+ * nested input-object string, W-23336442). `JSON.stringify` already escapes the
55
+ * C0 range (U+0000-U+001F) when it builds the quoted literal, but leaves DEL
56
+ * (U+007F) and the ENTIRE Cf class (bidi overrides, zero-width, BOM, tag block)
57
+ * raw inside the quotes; this post-pass converts those survivors so the emitted
58
+ * query still `graphql.parse()`s and carries no smuggled/reordering code points.
59
+ *
60
+ * WHY `\uXXXX`-ONLY (and NOT the `\xNN` / `\u{...}` forms that
61
+ * `neutralizeControlChars` in `schemas/tool-adapter.ts` emits): the output here
62
+ * is a GraphQL string literal, whose grammar accepts ONLY `\uXXXX` (four fixed
63
+ * hex digits) as a Unicode escape. `\xNN` is not a GraphQL escape at all — it
64
+ * would make the document un-parseable — and the variable-width `\u{...}` form
65
+ * is not accepted by every GraphQL parser. So this escaper deliberately DIVERGES
66
+ * from the plain-JSON `neutralizeControlChars` (whose `\xNN`/`\u{...}` output is
67
+ * fine for a JSON envelope but fatal in a query):
68
+ * - BMP code points -> a single lower-case `\uXXXX` (4 hex).
69
+ * - Astral code points (e.g. the U+E0000-E007F tag block) -> a UTF-16 surrogate
70
+ * PAIR `\uXXXX\uXXXX`, which is universally GraphQL-valid, rather than `\u{...}`.
71
+ * Ordinary Unicode (accented names, CJK labels, emoji) and the Zl/Zp separators
72
+ * are untouched — they are outside the Cc/Cf class (see {@link CONTROL_CHAR_RE}).
73
+ */
74
+ export function escapeControlCharsGraphQL(s: string): string {
75
+ return s.replace(CONTROL_CHAR_RE, (c) => {
76
+ // CONTROL_CHAR_RE carries the `u` flag, so an astral char matches as one
77
+ // code point; codePointAt(0) recovers its full scalar value.
78
+ const cp = c.codePointAt(0) ?? 0;
79
+ if (cp <= 0xffff) return `\\u${cp.toString(16).padStart(4, "0")}`;
80
+ // Astral: split into a high/low UTF-16 surrogate pair (never `\u{...}`).
81
+ const v = cp - 0x10000;
82
+ const hi = 0xd800 + (v >> 10);
83
+ const lo = 0xdc00 + (v & 0x3ff);
84
+ return `\\u${hi.toString(16).padStart(4, "0")}\\u${lo.toString(16).padStart(4, "0")}`;
85
+ });
86
+ }
87
+
50
88
  /**
51
89
  * Unicode line/paragraph separators (U+2028 Zl / U+2029 Zp). These are legal in
52
90
  * JSON strings and are NOT escaped by `JSON.stringify`, and left raw in
@@ -4,6 +4,7 @@
4
4
  * For full license text, see the LICENSE.txt file
5
5
  */
6
6
 
7
+ import { escapeControlCharsGraphQL } from "./control-chars.js";
7
8
  import { UserInputError } from "./errors.js";
8
9
  import { assertGraphqlName, GRAPHQL_NAME_RE } from "./graphql-name.js";
9
10
  import type { QuerySession, ProjectionNode, DirectiveNode } from "./session.js";
@@ -284,7 +285,10 @@ function formatArgValue(value: string): string {
284
285
  // single literal.
285
286
  if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
286
287
  try {
287
- if (typeof JSON.parse(trimmed) === "string") return trimmed;
288
+ // W-23336442: even a well-formed single literal can carry raw DEL/Cf
289
+ // inside the quotes (JSON permits them raw), so escape before passthrough
290
+ // (see the default branch below for the full rationale).
291
+ if (typeof JSON.parse(trimmed) === "string") return escapeControlCharsGraphQL(trimmed);
288
292
  } catch {
289
293
  // Not a single well-formed literal — re-encode below.
290
294
  }
@@ -295,7 +299,15 @@ function formatArgValue(value: string): string {
295
299
  // quotes/backslashes — so values like "a\nb" don't render a raw newline that
296
300
  // graphql.parse() would reject as an unterminated string. (U+2028/U+2029 pass
297
301
  // through raw and are handled at the MCP text boundary, not here.)
298
- return JSON.stringify(trimmed);
302
+ //
303
+ // W-23336442: JSON.stringify escapes ONLY the C0 range (U+0000-U+001F); it
304
+ // leaves DEL (U+007F) and the entire Cf class (bidi overrides, zero-width, BOM,
305
+ // tag block) RAW inside the quoted literal. This query is a LIVE GraphQL
306
+ // document reflected to the host, so those survivors would smuggle/reorder text
307
+ // (sf_gql_list scope/filter/orderBy values reach here verbatim). Post-pass with
308
+ // escapeControlCharsGraphQL, which emits GraphQL-valid `\uXXXX` (never `\xNN`,
309
+ // which GraphQL rejects) so the query still graphql.parse()s.
310
+ return escapeControlCharsGraphQL(JSON.stringify(trimmed));
299
311
  }
300
312
 
301
313
  /**
@@ -348,7 +360,13 @@ function valueToGraphQL(value: unknown): string {
348
360
  // terminators and control chars (\n \r \t \b \f), unlike a manual \ / " escape
349
361
  // which would leave a raw newline that graphql.parse() rejects. (U+2028/U+2029
350
362
  // pass through raw and are handled at the MCP text boundary, not here.)
351
- return JSON.stringify(value);
363
+ //
364
+ // W-23336442: JSON.stringify escapes only C0, leaving DEL (U+007F) + the Cf
365
+ // class raw inside the literal. This is a nested input-object string value in
366
+ // a LIVE GraphQL document (filter/orderBy values JSON.stringify'd into a
367
+ // `where` arg reach here), so post-pass with escapeControlCharsGraphQL to emit
368
+ // GraphQL-valid `\uXXXX` (never `\xNN`) — same rationale as formatArgValue.
369
+ return escapeControlCharsGraphQL(JSON.stringify(value));
352
370
  }
353
371
  if (Array.isArray(value)) {
354
372
  return `[${value.map(valueToGraphQL).join(", ")}]`;
@@ -9,6 +9,7 @@ import {
9
9
  AGGREGATE_INPUT,
10
10
  CREATE_INPUT,
11
11
  DETAIL_INPUT,
12
+ DISCOVER_INPUT,
12
13
  LIST_INPUT,
13
14
  UPDATE_INPUT,
14
15
  } from "../input-schemas.js";
@@ -151,3 +152,69 @@ describe("schemas/input-schemas — field-path charset validation (W-22735537)",
151
152
  ).toBe(true);
152
153
  });
153
154
  });
155
+
156
+ /**
157
+ * Contract for the `sf_gql_discover` `search` guard (W-23336442). `search` is a
158
+ * free-text substring filter reflected VERBATIM through the SUCCESS envelope
159
+ * (schemas/tool-adapter.ts runTool), which neutralizes neither Cc nor Cf —
160
+ * JSON.stringify escapes only C0 (U+0000-U+001F), leaving DEL (U+007F) and the
161
+ * ENTIRE Cf class (bidi overrides, zero-width, BOM, U+E0000-E007F tag block)
162
+ * raw. DISCOVER_SEARCH_RE must therefore REJECT the full Cc/Cf class, matching
163
+ * lib/control-chars.ts CONTROL_CHAR_RE — not just the C0/DEL subset the earlier
164
+ * /^[^\x00-\x1f\x7f]*$/ range caught.
165
+ *
166
+ * Injected chars are `\u` escapes, not literals, so the source carries no
167
+ * invisible bytes. Ordinary Unicode (café, 日本語) may appear as literals.
168
+ */
169
+ describe("schemas/input-schemas — DISCOVER_INPUT.search Cc/Cf guard (W-23336442)", () => {
170
+ const base = { org: "myorg", mode: "list_objects" as const };
171
+
172
+ const parseSearch = (search: string) => DISCOVER_INPUT.safeParse({ ...base, search });
173
+
174
+ describe("REJECTS control/format (Cc/Cf) chars", () => {
175
+ it.each([
176
+ // Cf class — the gap the old C0/DEL-only range let through.
177
+ ["ZWSP U+200B (Cf, zero-width)", "Acc\u{200b}ount"],
178
+ ["RLO U+202E (Cf, bidi override)", "Acc\u{202e}ount"],
179
+ ["tag char U+E0001 (Cf, tag block)", "Acc\u{e0001}ount"],
180
+ ["BOM/ZWNBSP U+FEFF (Cf)", "\u{feff}Account"],
181
+ ["soft hyphen U+00AD (Cf)", "Acc\u{00ad}ount"],
182
+ // Cc class — still rejected, as before.
183
+ ["NUL U+0000 (Cc, C0)", "Acc\u{0000}ount"],
184
+ ["ESC U+001B (Cc, C0)", "Acc\u{001b}ount"],
185
+ ["DEL U+007F (Cc)", "Acc\u{007f}ount"],
186
+ ["NEL U+0085 (Cc, C1)", "Acc\u{0085}ount"],
187
+ ])("%s is rejected", (_label, search) => {
188
+ const result = parseSearch(search);
189
+ expect(result.success).toBe(false);
190
+ if (!result.success) {
191
+ const searchIssue = result.error.issues.find((i) => i.path[0] === "search");
192
+ expect(searchIssue?.message).toBe("search must not contain control characters");
193
+ }
194
+ });
195
+ });
196
+
197
+ describe("ACCEPTS ordinary printable Unicode", () => {
198
+ it.each([
199
+ ["plain ASCII", "Account"],
200
+ ["accented Latin (café)", "café"],
201
+ ["CJK (日本語)", "日本語"],
202
+ ["spaces", "My Custom Object"],
203
+ ["punctuation and digits", "Order__c v2 (2026)"],
204
+ ["empty string", ""],
205
+ ])("%s is accepted", (_label, search) => {
206
+ const result = parseSearch(search);
207
+ expect(result.success).toBe(true);
208
+ if (result.success) expect(result.data.search).toBe(search);
209
+ });
210
+ });
211
+
212
+ it("still enforces the 100-char length cap alongside the Cc/Cf guard", () => {
213
+ const result = parseSearch("a".repeat(101));
214
+ expect(result.success).toBe(false);
215
+ if (!result.success) {
216
+ const searchIssue = result.error.issues.find((i) => i.path[0] === "search");
217
+ expect(searchIssue?.message).toBe("search must be 100 characters or fewer");
218
+ }
219
+ });
220
+ });