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:
1 parent
01174c4a81
commit
f0da05f67f
20 files changed
+126
-23
No files matched your search
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -177,7 +177,7 @@ const graphShape = {
|
||||
caller: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[\w .:@/+-]+$/u),
|
||||
.regex(/^[\w .:@\x2f+\x2d]+$/u),
|
||||
}),
|
||||
limitations: z.array(boundedTextSchema),
|
||||
};
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
Reference in new issue
Block a user