Skip to content

Commit b20b02a

Browse files
fix(tool-payload): enforce UTF-8 byte limits for serialized payloads (#102450)
* fix(tool-payload): reject oversized XML payload bytes * fix(tools): enforce serialized payload byte limits --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 5f91f6c commit b20b02a

3 files changed

Lines changed: 52 additions & 6 deletions

File tree

packages/tool-call-repair/src/payload.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,25 @@ export type PlainTextToolCallBlock = {
2929
export type PlainTextToolCallParseOptions = {
3030
/** Optional allowlist of tool names that may be repaired. */
3131
allowedToolNames?: Iterable<string>;
32-
/** Maximum JSON payload size accepted for one repaired call. */
32+
/** Maximum serialized payload size accepted for one repaired call. */
3333
maxPayloadBytes?: number;
3434
};
3535

3636
const DEFAULT_MAX_PLAIN_TEXT_TOOL_PAYLOAD_BYTES = 256_000;
37+
const utf8Encoder = new TextEncoder();
38+
39+
function utf8ByteLengthWithinLimit(
40+
text: string,
41+
start: number,
42+
end: number,
43+
maxBytes: number,
44+
): number | null {
45+
if (end - start > maxBytes) {
46+
return null;
47+
}
48+
const byteLength = utf8Encoder.encode(text.slice(start, end)).byteLength;
49+
return byteLength <= maxBytes ? byteLength : null;
50+
}
3751

3852
type PlainTextToolCallOpening = {
3953
allowsOptionalXmlishClose?: boolean;
@@ -140,7 +154,7 @@ function consumeJsonObject(
140154
return null;
141155
}
142156
const end = findJsonObjectEnd(text, cursor, maxPayloadBytes);
143-
if (end === null) {
157+
if (end === null || utf8ByteLengthWithinLimit(text, cursor, end, maxPayloadBytes) === null) {
144158
return null;
145159
}
146160
const rawJson = text.slice(cursor, end);
@@ -247,15 +261,17 @@ function consumeXmlishParameterBlock(
247261
text: string,
248262
start: number,
249263
maxPayloadBytes: number,
250-
): { end: number; name: string; value: string } | null {
264+
): { byteLength: number; end: number; name: string; value: string } | null {
251265
const bounds = findXmlishParameterBlock(text, start);
252266
if (!bounds) {
253267
return null;
254268
}
255-
if (bounds.end - bounds.start > maxPayloadBytes) {
269+
const byteLength = utf8ByteLengthWithinLimit(text, start, bounds.end, maxPayloadBytes);
270+
if (byteLength === null) {
256271
return null;
257272
}
258273
return {
274+
byteLength,
259275
end: bounds.end,
260276
name: bounds.name,
261277
value: extractXmlishParameterValue(text, bounds.payloadStart, bounds.closeStart),
@@ -339,12 +355,14 @@ function parseXmlishPlainTextToolCallBlockAt(
339355
const args: Record<string, unknown> = {};
340356
let cursor = opening.end;
341357
let parameterCount = 0;
358+
let payloadBytes = 0;
342359
while (true) {
343360
const parameter = consumeXmlishParameterBlock(text, cursor, maxPayloadBytes);
344361
if (!parameter) {
345362
break;
346363
}
347-
if (parameter.end - opening.end > maxPayloadBytes) {
364+
payloadBytes += parameter.byteLength;
365+
if (payloadBytes > maxPayloadBytes) {
348366
return null;
349367
}
350368
args[parameter.name] = parameter.value;

src/plugin-sdk/tool-payload.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,34 @@ describe("parseStandalonePlainTextToolCallBlocks", () => {
242242
).toBeNull();
243243
});
244244

245+
it("counts serialized XML parameter payload limits in UTF-8 bytes", () => {
246+
const parameter = ["<parameter=content>", "é".repeat(20), "</parameter>"].join("\n");
247+
const raw = ["<function=write>", parameter, "</function>"].join("\n");
248+
expect(parameter.length).toBeLessThan(64);
249+
expect(new TextEncoder().encode(parameter).byteLength).toBeGreaterThan(64);
250+
251+
expect(
252+
parseStandalonePlainTextToolCallBlocks(raw, {
253+
allowedToolNames: ["write"],
254+
maxPayloadBytes: 64,
255+
}),
256+
).toBeNull();
257+
});
258+
259+
it("counts serialized JSON payload limits in UTF-8 bytes", () => {
260+
const payload = JSON.stringify({ content: "é".repeat(20) });
261+
const raw = ["[write]", payload, "[/write]"].join("\n");
262+
expect(payload.length).toBeLessThan(48);
263+
expect(new TextEncoder().encode(payload).byteLength).toBeGreaterThan(48);
264+
265+
expect(
266+
parseStandalonePlainTextToolCallBlocks(raw, {
267+
allowedToolNames: ["write"],
268+
maxPayloadBytes: 48,
269+
}),
270+
).toBeNull();
271+
});
272+
245273
it("respects allowed tool names for Harmony calls", () => {
246274
const blocks = parseStandalonePlainTextToolCallBlocks(
247275
'commentary to=write code {"path":"/tmp/file.txt","content":"x"}',

src/plugin-sdk/tool-payload.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export type PlainTextToolCallBlock = {
2222
export type PlainTextToolCallParseOptions = {
2323
/** Optional allowlist of tool names that may be accepted. */
2424
allowedToolNames?: Iterable<string>;
25-
/** Maximum JSON payload size accepted for one parsed call. */
25+
/** Maximum serialized payload size accepted for one parsed call. */
2626
maxPayloadBytes?: number;
2727
};
2828

0 commit comments

Comments
 (0)