From 3f59a84bd1039763bb349894542601ed78be2033 Mon Sep 17 00:00:00 2001 From: yi111 <153097222+Yi-111-a@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:54:42 +0800 Subject: [PATCH] fix(contracts): advertise NUL with a portable hex escape Use the equivalent hex NUL spelling in named public contracts while preserving current path and reserved-name validation. Verify converted JSON Schema and real stdio catalog consumers. Closes #1329 Co-authored-by: Yi-111-a <97448247+Yi-111-a@users.noreply.github.com> --- src/contracts/native/nativeToolContracts.ts | 2 +- src/domain/apple/dylibResolution.ts | 2 +- src/domain/javascript/electronActiveObservation.ts | 2 +- src/domain/localPath.ts | 12 ++++++++++-- src/domain/native/nativeCallObservation.ts | 8 +++----- src/domain/process/processScenario.ts | 6 +++--- tests/boundary/mcp/advertisedPatternsVMode.test.ts | 13 ++++++++----- 7 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/contracts/native/nativeToolContracts.ts b/src/contracts/native/nativeToolContracts.ts index af1e1f0163..093add61e4 100644 --- a/src/contracts/native/nativeToolContracts.ts +++ b/src/contracts/native/nativeToolContracts.ts @@ -71,7 +71,7 @@ export const swiftSymbolsSchema = z .string() .min(1) .regex(/^[^\n]*$/u, "Each Swift symbol must be one line.") - .regex(/^[^\u0000]*$/u, "Swift symbols cannot contain NUL."), + .regex(/^[^\x00]*$/u, "Swift symbols cannot contain NUL."), ) .min(1); diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts index 466344d6a6..86dc4902e7 100644 --- a/src/domain/apple/dylibResolution.ts +++ b/src/domain/apple/dylibResolution.ts @@ -25,7 +25,7 @@ import { * patterns avoid look-around, which RE2 and Rust validators reject. */ const ROOT_RELATIVE_PATH = - /^(?:[^\x2f\\\u0000.][^\x2f\\\u0000]*|\.[^\x2f\\\u0000.][^\x2f\\\u0000]*|\.\.[^\x2f\\\u0000]+)(?:\x2f(?:[^\x2f\\\u0000.][^\x2f\\\u0000]*|\.[^\x2f\\\u0000.][^\x2f\\\u0000]*|\.\.[^\x2f\\\u0000]+))*$/u; + /^(?:[^\x2f\\\x00.][^\x2f\\\x00]*|\.[^\x2f\\\x00.][^\x2f\\\x00]*|\.\.[^\x2f\\\x00]+)(?:\x2f(?:[^\x2f\\\x00.][^\x2f\\\x00]*|\.[^\x2f\\\x00.][^\x2f\\\x00]*|\.\.[^\x2f\\\x00]+))*$/u; const rootRelativePathSchema = z .string() .min(1) diff --git a/src/domain/javascript/electronActiveObservation.ts b/src/domain/javascript/electronActiveObservation.ts index 9f2e90b031..f46acfe6c9 100644 --- a/src/domain/javascript/electronActiveObservation.ts +++ b/src/domain/javascript/electronActiveObservation.ts @@ -81,7 +81,7 @@ export const electronActiveObservationInputSchema = z.strictObject({ .array( z .string() - .regex(/^[^\u0000]*$/u, "Electron arguments must not contain NUL"), + .regex(/^[^\x00]*$/u, "Electron arguments must not contain NUL"), ) .default([]), actions: z.array(actionSchema).default([]), diff --git a/src/domain/localPath.ts b/src/domain/localPath.ts index 3226c2e442..d3d2001896 100644 --- a/src/domain/localPath.ts +++ b/src/domain/localPath.ts @@ -1,11 +1,19 @@ import { isAbsolute, relative, sep } from "node:path"; import { z } from "zod"; -/** Preserve selected filesystem names while rejecting OS-invalid NUL bytes. */ +/** + * Preserve selected filesystem names while rejecting OS-invalid NUL bytes. + * + * The pattern spells NUL as `\x00` rather than `\u0000`. Both accept exactly the + * same strings. The hex form also avoids the short `\0` spelling rejected by + * strict schema consumers. A client can reject its entire tool request when + * one advertised pattern cannot compile. See `offsetDateTimeSchema` for the + * same hex-escape convention. + */ export const localPathStringSchema = z .string() .min(1) - .regex(/^[^\u0000]*$/u, "Local filesystem paths cannot contain NUL"); + .regex(/^[^\x00]*$/u, "Local filesystem paths cannot contain NUL"); /** Test lexical containment on the host platform; callers must resolve symlinks first. */ export const isPathWithinRoot = (root: string, path: string): boolean => { diff --git a/src/domain/native/nativeCallObservation.ts b/src/domain/native/nativeCallObservation.ts index c43a971954..68a56e5690 100644 --- a/src/domain/native/nativeCallObservation.ts +++ b/src/domain/native/nativeCallObservation.ts @@ -5,7 +5,7 @@ const hexSchema = z.string().regex(/^0x[0-9a-f]+$/u); const nativeString = z .string() .regex( - /^[^\u0000]*$/u, + /^[^\x00]*$/u, "Native launch and breakpoint strings cannot contain NUL", ); /** Objective-C class names and selectors never contain spaces or brackets. */ @@ -61,10 +61,8 @@ export const nativeCallObservationInputSchema = z.strictObject({ z .string() .min(1) - .regex(/^[^=\u0000]+$/u, "Environment names cannot contain '=' or NUL"), - z - .string() - .regex(/^[^\u0000]*$/u, "Environment values cannot contain NUL"), + .regex(/^[^=\x00]+$/u, "Environment names cannot contain '=' or NUL"), + z.string().regex(/^[^\x00]*$/u, "Environment values cannot contain NUL"), ) .default({}) .describe("Overrides on top of the environment REA runs with."), diff --git a/src/domain/process/processScenario.ts b/src/domain/process/processScenario.ts index 8b980faa0b..8312e1d2a1 100644 --- a/src/domain/process/processScenario.ts +++ b/src/domain/process/processScenario.ts @@ -5,7 +5,7 @@ import { z } from "zod"; const positiveBudget = z.number().int().safe().positive(); const timedEventBase = { at_ms: z.number().int().safe().nonnegative() }; const reservedRunIdEnvironmentName = "REA_PROCESS_RUN_ID"; -const ENVIRONMENT_NAME_CHARACTER = "[^=\\u0000]"; +const ENVIRONMENT_NAME_CHARACTER = "[^=\\x00]"; /** * Nonempty environment names other than the reserved run identifier. The @@ -20,7 +20,7 @@ const environmentNamePattern = (reserved: string): RegExp => { const character = reserved[index] ?? ""; // Diverge here, match the next reserved character, or (after a nonempty // prefix) end the name early. - const choice = `[^=\\u0000${character}]${ENVIRONMENT_NAME_CHARACTER}*|${character}${rest}`; + const choice = `[^=\\x00${character}]${ENVIRONMENT_NAME_CHARACTER}*|${character}${rest}`; rest = index === 0 ? choice : `(?:${choice})?`; } return new RegExp(`^(?:${rest})$`, "u"); @@ -38,7 +38,7 @@ const environmentName = z const childProcessString = z .string() .regex( - /^[^\u0000]*$/u, + /^[^\x00]*$/u, "Values passed to operating-system APIs cannot contain NUL", ); export const normalizationSchema = z.object({ diff --git a/tests/boundary/mcp/advertisedPatternsVMode.test.ts b/tests/boundary/mcp/advertisedPatternsVMode.test.ts index 024c42d128..b48fe27aa0 100644 --- a/tests/boundary/mcp/advertisedPatternsVMode.test.ts +++ b/tests/boundary/mcp/advertisedPatternsVMode.test.ts @@ -79,9 +79,12 @@ it("advertises portable NUL escapes and patterns that compile in all JS modes", ).toEqual([]); // DeepSeek rejects the short NUL escape even though V8 accepts it in all - // three modes. Schema compilation must not hide that interchange failure. - expect(patterns.filter(({ pattern }) => pattern.includes("\\0"))).toEqual([]); - expect(patterns.some(({ pattern }) => pattern.includes("\\u0000"))).toBe( - true, - ); + // three modes. Keep the shared hex spelling across the advertised catalog; + // actual provider acceptance is verified separately from JS compilation. + expect( + patterns.filter( + ({ pattern }) => pattern.includes("\\0") || pattern.includes("\\u0000"), + ), + ).toEqual([]); + expect(patterns.some(({ pattern }) => pattern.includes("\\x00"))).toBe(true); });