diff --git a/src/adapters/responses-tool-schema.ts b/src/adapters/responses-tool-schema.ts index 6b28cca6eae..ed679464fdf 100644 --- a/src/adapters/responses-tool-schema.ts +++ b/src/adapters/responses-tool-schema.ts @@ -96,9 +96,32 @@ function usesUnicodePropertyEscape(pattern: string): boolean { return false; } +/** + * Rewrite each unescaped `\0` not followed by a digit to the equivalent `\x00`. Both spell NUL + * in ECMAScript and Python `re`, but some destinations' schema validators refuse `\0` inside a + * character class: Meta's Responses API answers 400 "is not a \"regex\"" to Claude Code's + * Artifact tool, whose file-path parameters carry `^[^\0]*$`. `\0` followed by a digit is an + * octal escape in Python, so it is left alone. Returns the input when nothing changed. + */ +function rewriteNulEscapes(pattern: string): string { + let out: string | undefined; + let copied = 0; + for (let i = 0; i < pattern.length; i++) { + if (pattern[i] !== "\\") continue; + const next = pattern[i + 1]; + if (next === "0" && !/[0-9]/.test(pattern[i + 2] ?? "")) { + out = (out ?? "") + pattern.slice(copied, i) + "\\x00"; + copied = i + 2; + } + i++; + } + return out === undefined ? pattern : out + pattern.slice(copied); +} + /** * Remove unsupported Unicode property escapes from scalar `pattern` constraints in ordinary - * positive schema positions. This keeps built-in Artifact tools usable on Python-re backends; + * positive schema positions, and spell `\0` as `\x00` (rewriteNulEscapes). This keeps built-in + * Artifact tools usable on Python-re backends and on Meta's Responses API; * the omitted constraint is not enforced by this proxy and tools must validate their inputs. * * Regex-keyed objects are preserved. Removing a matcher can lose evaluated-property annotations @@ -181,8 +204,13 @@ export function stripUnicodePropertyPatterns(node: unknown, inNameBag = false): continue; } const [key, value] = next.value; - if (!frame.inNameBag && key === "pattern" && typeof value === "string" && usesUnicodePropertyEscape(value)) { - delete (cloneContainer(frame) as Record)[key]; + if (!frame.inNameBag && key === "pattern" && typeof value === "string") { + if (usesUnicodePropertyEscape(value)) { + delete (cloneContainer(frame) as Record)[key]; + continue; + } + const rewritten = rewriteNulEscapes(value); + if (rewritten !== value) (cloneContainer(frame) as Record)[key] = rewritten; continue; } if (!frame.inNameBag && (PRESERVED_PATTERN_SUBTREES.has(key) || SCHEMA_LITERAL_VALUE_KEYS.has(key))) { diff --git a/tests/adapters/openai/openai-chat-hardening.test.ts b/tests/adapters/openai/openai-chat-hardening.test.ts index 492dca51c08..109ec05aea6 100644 --- a/tests/adapters/openai/openai-chat-hardening.test.ts +++ b/tests/adapters/openai/openai-chat-hardening.test.ts @@ -362,6 +362,29 @@ describe("unicode property-escape pattern stripping", () => { expect(stripUnicodePropertyPatterns(before)).toBe(before); }); + test("`\\0` is rewritten to the equivalent `\\x00`, octal and escaped backslashes untouched", () => { + // Claude Code's Artifact tool ships `^[^\0]*$` on its file-path parameters; Meta's + // Responses API rejects `\0` inside a character class but accepts `\x00`. + const artifactPathPattern = "^[^\\0]*$"; + const before = { + type: "object", + properties: { + path: { type: "string", pattern: artifactPathPattern, maxLength: 1024 }, + octal: { type: "string", pattern: "^\\012$" }, + literal: { type: "string", pattern: "^\\\\0$" }, + }, + }; + const out = stripUnicodePropertyPatterns(before) as typeof before; + expect(out.properties.path.pattern).toBe("^[^\\x00]*$"); + expect(out.properties.path.maxLength).toBe(1024); + expect(out.properties.octal).toBe(before.properties.octal); + expect(out.properties.literal).toBe(before.properties.literal); + expect(before.properties.path.pattern).toBe(artifactPathPattern); + for (const s of ["\u0000", "a", "\u0000/b"]) { + expect(new RegExp(out.properties.path.pattern).test(s)).toBe(new RegExp(artifactPathPattern).test(s)); + } + }); + test("`\\P{…}` is dropped as well as `\\p{…}`", () => { const stripped = stripUnicodePropertyPatterns({ type: "string", pattern: "^\\P{L}+$" }) as Record; expect(stripped.pattern).toBeUndefined();