@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.
- package/CHANGELOG.md +10 -0
- package/dist/commands/mcp-mirror/run-mirror.js +15 -5
- package/dist/commands/mcp-mirror/run-mirror.js.map +1 -1
- package/dist/intent/build-discover.js +16 -3
- package/dist/intent/build-discover.js.map +1 -1
- package/dist/lib/control-chars.d.ts +25 -0
- package/dist/lib/control-chars.js +38 -0
- package/dist/lib/control-chars.js.map +1 -1
- package/dist/lib/query-builder.js +21 -3
- package/dist/lib/query-builder.js.map +1 -1
- package/dist/schemas/input-schemas.js +11 -2
- package/dist/schemas/input-schemas.js.map +1 -1
- package/dist/schemas/tool-adapter.d.ts +9 -0
- package/dist/schemas/tool-adapter.js +5 -2
- package/dist/schemas/tool-adapter.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/mcp-mirror/__tests__/run-mirror.spec.ts +115 -0
- package/src/commands/mcp-mirror/run-mirror.ts +15 -5
- package/src/intent/__tests__/build-discover.spec.ts +162 -0
- package/src/intent/build-discover.ts +16 -3
- package/src/lib/__tests__/control-chars.spec.ts +68 -0
- package/src/lib/__tests__/query-builder.spec.ts +128 -0
- package/src/lib/control-chars.ts +38 -0
- package/src/lib/query-builder.ts +21 -3
- package/src/schemas/__tests__/input-schemas.spec.ts +67 -0
- package/src/schemas/input-schemas.ts +11 -2
- package/src/schemas/tool-adapter.ts +5 -2
|
@@ -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
|
|
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
|
|
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(
|
|
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`
|
|
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
|
|
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
|
});
|
package/src/lib/control-chars.ts
CHANGED
|
@@ -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
|
package/src/lib/query-builder.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|