@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
|
@@ -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
|
+
});
|
package/src/schemas/fields.ts
CHANGED
|
@@ -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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
//
|
|
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
|
-
//
|
|
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{...}`; `
|
|
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(
|
|
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")}`;
|