fix: emit v-mode-compatible patterns for tool schemas (#1220)

* fix: emit v-mode-compatible patterns for tool schemas

Character classes in emitted patterns carried unescaped '-', '/' and '['. Annex-B and u-mode accept them, so REA's own Zod validation (u) and every u-based validator never saw a defect, but v-mode rejects ClassSetSyntaxCharacters and Ruby rejects [^s[]]. With 6.1.0 tools/list, 117 pattern fields across 11 tools were rejected under v.

The characters are escaped as \x2d, \x2f, \x5b and \x5d rather than \-/\//\[, because oxlint --fix removes an escaped '-' inside a character class as a useless escape, so an escape-only change does not survive the pre-commit hook. \x2d-style escapes are valid under Annex B, u and v, are matched identically, and are not useless-escape candidates.

22 pattern literals in 18 files are touched; no other token changes.

* fix: replace the Zod offset pattern and guard the advertised catalog

The offset datetime site projected Zod's offset class as [ +- ], which no escape in this repository's sources can reach, so evaluate_reconstruction_coverage stayed unreachable for exactly the clients this change targets. It now uses offsetDateTimeSchema in src/domain/offsetDateTime.ts: the same accepted set with the offset class escaped as [ +\x2d ].

Adds tests/boundary/mcp/advertisedPatternsVMode.test.ts, which performs the real tools/list projection, traverses every advertised input and output schema, compiles each pattern under v, and asserts the inventory it walked so a silent traversal collapse fails instead of passing vacuously.
This commit is contained in:
felipeatp authored and GitHub committed 2026-10-09 16:20:57 +08:00
1 parent 01174c4a81
commit f0da05f67f
20 files changed
+126 -23

No files matched your search

+3 -3
View File
@@ -92,7 +92,7 @@ const parseCsp = (
for (const rawDirective of (value ?? "").split(";")) {
const [rawName, ...tokens] = rawDirective.trim().split(/\s+/u);
const name = (rawName ?? "").toLowerCase();
if (!/^[a-z][a-z0-9-]*$/u.test(name)) continue;
if (!/^[a-z][a-z0-9\x2d]*$/u.test(name)) continue;
const sources: ResponseMetadata["csp"]["directives"][number]["sources"] =
[];
for (const token of tokens) {
@@ -249,7 +249,7 @@ const linkParameters = (value: string): ReadonlyMap<string, string> => {
const name = (separator < 0 ? raw : raw.slice(0, separator))
.trim()
.toLowerCase();
if (!/^[a-z][a-z0-9-]*$/u.test(name)) continue;
if (!/^[a-z][a-z0-9\x2d]*$/u.test(name)) continue;
const parameter = separator < 0 ? "" : raw.slice(separator + 1).trim();
parameters.set(name, unquote(parameter));
}
@@ -285,7 +285,7 @@ const permissionFeatures = (value: string | undefined): string[] =>
(value ?? "")
.split(",")
.map((entry) => entry.split("=", 1)[0]?.trim().toLowerCase() ?? "")
.filter((feature) => /^[a-z][a-z0-9-]*$/u.test(feature)),
.filter((feature) => /^[a-z][a-z0-9\x2d]*$/u.test(feature)),
),
].sort();
+1 -1
View File
@@ -4,7 +4,7 @@ import { z } from "zod";
export const analysisProviderIdSchema = z
.string()
.min(1)
.regex(/^[a-z][a-z0-9-]*$/u)
.regex(/^[a-z][a-z0-9\x2d]*$/u)
.refine((value) => value !== "auto", {
message: "auto is reserved for provider selection",
});
+2 -2
View File
@@ -10,7 +10,7 @@ import {
export const scenarioIdentifierSchema = z
.string()
.regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
.regex(/^[A-Za-z][A-Za-z0-9._\x2d]*$/u);
const browserExecutablePathSchema = localPathStringSchema.refine(
isAbsoluteLocalPath,
@@ -108,7 +108,7 @@ export const browserScenarioEnvironmentSchema = z
timezone: z
.string()
.min(1)
.regex(/^[A-Za-z0-9_+-]+(?:\/[A-Za-z0-9_+-]+)*$/u)
.regex(/^[A-Za-z0-9_+\x2d]+(?:\x2f[A-Za-z0-9_+\x2d]+)*$/u)
.default("UTC"),
color_scheme: z.enum(["light", "dark", "no-preference"]).default("light"),
reduced_motion: z.enum(["reduce", "no-preference"]).default("reduce"),
+4 -2
View File
@@ -6,10 +6,12 @@ import { prefixedDigestSchema } from "./../domain/digests.js";
const addressSchema = z
.string()
.regex(/^(?:(?:[A-Za-z0-9._~-]|%[0-9A-F]{2})+:)?0x(?:0|[1-9a-f][0-9a-f]*)$/u);
.regex(
/^(?:(?:[A-Za-z0-9._~\x2d]|%[0-9A-F]{2})+:)?0x(?:0|[1-9a-f][0-9a-f]*)$/u,
);
const inputAddressSchema = z
.string()
.regex(/^(?:(?:[A-Za-z0-9._~-]|%[0-9a-fA-F]{2})+:)?0[xX][0-9a-fA-F]+$/u)
.regex(/^(?:(?:[A-Za-z0-9._~\x2d]|%[0-9a-fA-F]{2})+:)?0[xX][0-9a-fA-F]+$/u)
.transform((address) => {
const separator = address.lastIndexOf(":") + 1;
const space = address
+1 -1
View File
@@ -11,7 +11,7 @@ import {
import { digestSchema } from "./../domain/digests.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._\x2d]*$/u);
const identitySchema = z.strictObject({
id: identifierSchema,
version: z.string().min(1),
+1 -1
View File
@@ -10,7 +10,7 @@ import { prefixedDigestSchema } from "./../domain/digests.js";
const conformancePackageIdSchema = prefixedDigestSchema("cp");
const scenarioIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
const scenarioIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._\x2d]*$/u);
const scenarioManifestSchema = z.strictObject({
scenario_id: scenarioIdSchema,
+1 -1
View File
@@ -5,7 +5,7 @@ import { z } from "zod";
import { prefixedDigestSchema } from "./../domain/digests.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const claimIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
const claimIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._\x2d]*$/u);
/** Terminal outcome recorded for one verifier claim. */
export const evidenceCompletionStatusSchema = z.enum([
+1 -1
View File
@@ -7,7 +7,7 @@ const range = z.strictObject({ offset: unsignedHex, bytes: unsignedHex });
const canonicalBase64 = z
.string()
.regex(
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/][AQgw]==|[A-Za-z0-9+/]{2}[AEIMQUYcgkosw048]=)?$/,
/^(?:[A-Za-z0-9+\x2f]{4})*(?:[A-Za-z0-9+\x2f][AQgw]==|[A-Za-z0-9+\x2f]{2}[AEIMQUYcgkosw048]=)?$/,
);
const name = z.strictObject({
display: z.string(),
+1 -1
View File
@@ -11,7 +11,7 @@ const nativeString = z
/** Objective-C class names and selectors never contain spaces or brackets. */
const objcName = nativeString
.min(1)
.regex(/^[^\s[\]]+$/u, "Use the bare name without spaces or brackets");
.regex(/^[^\s\x5b\x5d]+$/u, "Use the bare name without spaces or brackets");
const functionBreakpoint = {
kind: z.literal("function"),
+1 -1
View File
@@ -7,7 +7,7 @@ const signed32 = z.number().int().min(-2147483648).max(2147483647);
const base64 = z
.string()
.regex(
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/][AQgw]==|[A-Za-z0-9+/]{2}[AEIMQUYcgkosw048]=)?$/,
/^(?:[A-Za-z0-9+\x2f]{4})*(?:[A-Za-z0-9+\x2f][AQgw]==|[A-Za-z0-9+\x2f]{2}[AEIMQUYcgkosw048]=)?$/,
);
const range = z.strictObject({ offset: hex, bytes: hex });
const diagnostics = z.strictObject({
+21
View File
@@ -0,0 +1,21 @@
import { z } from "zod";
/**
* ISO-8601 date-time that requires an explicit UTC offset.
*
* `z.string().datetime({ offset: true })` emits Zod's offset class as `[+-]`.
* Annex B and `u` mode accept that, but ECMA-262 `v` mode rejects the
* unescaped `-`, so any tool whose schema carries the pattern is unusable for a
* client that compiles advertised patterns — schema compilation happens before
* arguments are supplied, so the whole tools request fails rather than one call.
*
* The accepted set is identical to the Zod validator; only the offset class
* escapes `-`, as `\x2d`, because an escaped `-` inside a character class is
* removed by `oxlint --fix` as a useless escape.
*/
export const offsetDateTimeSchema = z
.string()
.regex(
/^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|(?:[+\x2d](?:[01]\d|2[0-3]):[0-5]\d)))$/u,
"Use an ISO-8601 date-time with an explicit UTC offset",
);
+1 -1
View File
@@ -15,7 +15,7 @@ import {
type ProcessTraceSpecification,
} from "./processTraceSpecification.js";
const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]{0,63}$/u);
const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._\x2d]{0,63}$/u);
export type ProcessTraceLocation = ProcessObservationLocation;
@@ -8,7 +8,7 @@ import {
type ProcessObservationSource,
} from "./processObservation.js";
const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._\x2d]*$/u);
/** Process observation families admitted by a declared trace specification. */
export const processTraceSourceSchema = processObservationSourceSchema;
+5 -2
View File
@@ -14,9 +14,12 @@ import {
} from "./reconstructionCoverageEvaluation.js";
import { digestSchema } from "./../domain/digests.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
import { offsetDateTimeSchema } from "./offsetDateTime.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const stableIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._:/-]{0,199}$/u);
const stableIdSchema = z
.string()
.regex(/^[A-Za-z][A-Za-z0-9._:\x2f\x2d]{0,199}$/u);
const boundedTextSchema = z.string().trim().min(1);
const authoritySchema = z.enum([
@@ -106,7 +109,7 @@ const verifierContractSchema = verifierContractBaseSchema.extend({
const verifierResultSchema = z.strictObject({
verifier_id: stableIdSchema,
contract_sha256: digestSchema,
observed_at: z.string().datetime({ offset: true }),
observed_at: offsetDateTimeSchema,
status: z.enum([
"pass",
"fail",
@@ -5,7 +5,7 @@ import { digestSchema } from "./../domain/digests.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const stableIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._:/-]*$/u);
const stableIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._:\x2f\x2d]*$/u);
const textSchema = z.string().trim().min(1);
export const obligationStatusSchema = z.enum([
+1 -1
View File
@@ -6,7 +6,7 @@ import { digestSchema } from "./../domain/digests.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const stableIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._:/-]*$/u);
const stableIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._:\x2f\x2d]*$/u);
const boundedTextSchema = z.string().trim().min(1);
export const readinessStatusSchema = z.enum([
@@ -5,7 +5,7 @@ import { PROCESS_COMPARISON_DIMENSIONS } from "./process/processComparison.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const unknownIdSchema = prefixedDigestSchema("unk");
const claimIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
const claimIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._\x2d]*$/u);
const titleSchema = z.string().trim().min(1);
const verificationStatusSchema = z.enum(["pass", "fail", "unknown"]);
const commonClaim = {
+1 -1
View File
@@ -177,7 +177,7 @@ const graphShape = {
caller: z
.string()
.min(1)
.regex(/^[\w .:@/+-]+$/u),
.regex(/^[\w .:@\x2f+\x2d]+$/u),
}),
limitations: z.array(boundedTextSchema),
};
+1 -1
View File
@@ -19,7 +19,7 @@ import { nativeAotAdapter } from "./nativeaot/NativeAotAdapter.js";
const digest = z.string().regex(/^[a-f0-9]{64}$/u);
/** Exact executable extension artifact committed before the session starts. */
export const ghidraExtensionSchema = z.strictObject({
id: z.string().regex(/^[a-z][a-z0-9-]*$/u),
id: z.string().regex(/^[a-z][a-z0-9\x2d]*$/u),
path: z.string().refine(isAbsolute, "Extension path must be absolute"),
configured_path: z
.string()
@@ -0,0 +1,77 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it, onTestFinished } from "vitest";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
interface AdvertisedPattern {
readonly tool: string;
readonly where: string;
readonly pattern: string;
}
const collectPatterns = (
value: unknown,
where: string,
tool: string,
found: AdvertisedPattern[],
): void => {
if (value === null || typeof value !== "object") return;
if (Array.isArray(value)) {
value.forEach((entry, index) =>
collectPatterns(entry, `${where}[${String(index)}]`, tool, found),
);
return;
}
for (const [key, entry] of Object.entries(value)) {
if (key === "pattern" && typeof entry === "string")
found.push({ tool, where: `${where}/pattern`, pattern: entry });
collectPatterns(entry, `${where}/${key}`, tool, found);
}
};
it("advertises patterns that compile under every engine a client may use", async () => {
const session = createTestBinarySession(() => {
throw new Error("No deep provider may start for a schema projection");
});
const server = createServer(session, session);
const client = new Client({ name: "advertised-patterns", version: "1" });
onTestFinished(async () => {
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const advertised = (await client.listTools()).tools;
const patterns: AdvertisedPattern[] = [];
for (const tool of advertised) {
collectPatterns(tool.inputSchema, "inputSchema", tool.name, patterns);
if (tool.outputSchema !== undefined)
collectPatterns(tool.outputSchema, "outputSchema", tool.name, patterns);
}
// A traversal that silently collapses would make the compilation check below
// vacuous, so the inventory itself is asserted.
expect(advertised.length).toBeGreaterThan(100);
expect(patterns.length).toBeGreaterThan(500);
// Annex B and `u` mode accept an unescaped `-`, `/` or `[` inside a character
// class, so REA's own schema validation cannot observe the defect. `v` mode
// rejects them, and a client that compiles advertised patterns rejects the
// complete tools request rather than the affected call.
const uncompilable = patterns.flatMap((entry) => {
try {
new RegExp(entry.pattern, "v");
return [];
} catch (cause: unknown) {
return [
`${entry.tool} ${entry.where} ${entry.pattern}: ${cause instanceof Error ? cause.message : String(cause)}`,
];
}
});
expect(uncompilable).toEqual([]);
});