diff --git a/src/browser/CdpSafeMetadata.ts b/src/browser/CdpSafeMetadata.ts index c3aee0f94e..6921411cf6 100644 --- a/src/browser/CdpSafeMetadata.ts +++ b/src/browser/CdpSafeMetadata.ts @@ -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 => { 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(); diff --git a/src/contracts/providerSelection.ts b/src/contracts/providerSelection.ts index 41d0a18046..d43ee58528 100644 --- a/src/contracts/providerSelection.ts +++ b/src/contracts/providerSelection.ts @@ -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", }); diff --git a/src/domain/browserScenarioValues.ts b/src/domain/browserScenarioValues.ts index 0344b628cf..f6f223b42a 100644 --- a/src/domain/browserScenarioValues.ts +++ b/src/domain/browserScenarioValues.ts @@ -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"), diff --git a/src/domain/callPathSchemas.ts b/src/domain/callPathSchemas.ts index 130b7177c8..a7286e3665 100644 --- a/src/domain/callPathSchemas.ts +++ b/src/domain/callPathSchemas.ts @@ -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 diff --git a/src/domain/completionLedgerGeneration.ts b/src/domain/completionLedgerGeneration.ts index 5afe3d9d5f..a1f95bdd66 100644 --- a/src/domain/completionLedgerGeneration.ts +++ b/src/domain/completionLedgerGeneration.ts @@ -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), diff --git a/src/domain/conformancePackage.ts b/src/domain/conformancePackage.ts index 0bf6720ee2..2791c7f61a 100644 --- a/src/domain/conformancePackage.ts +++ b/src/domain/conformancePackage.ts @@ -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, diff --git a/src/domain/evidenceCompletionLedger.ts b/src/domain/evidenceCompletionLedger.ts index 50bec70609..ab4c859934 100644 --- a/src/domain/evidenceCompletionLedger.ts +++ b/src/domain/evidenceCompletionLedger.ts @@ -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([ diff --git a/src/domain/native/binaryLayout.ts b/src/domain/native/binaryLayout.ts index 95a55b8669..eed2809689 100644 --- a/src/domain/native/binaryLayout.ts +++ b/src/domain/native/binaryLayout.ts @@ -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(), diff --git a/src/domain/native/nativeCallObservation.ts b/src/domain/native/nativeCallObservation.ts index b4e5495f8d..8e4e0dbd22 100644 --- a/src/domain/native/nativeCallObservation.ts +++ b/src/domain/native/nativeCallObservation.ts @@ -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"), diff --git a/src/domain/native/recordedCrash.ts b/src/domain/native/recordedCrash.ts index 7668e63ce2..6639c447b6 100644 --- a/src/domain/native/recordedCrash.ts +++ b/src/domain/native/recordedCrash.ts @@ -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({ diff --git a/src/domain/offsetDateTime.ts b/src/domain/offsetDateTime.ts new file mode 100644 index 0000000000..bccda7494a --- /dev/null +++ b/src/domain/offsetDateTime.ts @@ -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", + ); diff --git a/src/domain/process/processTraceEvaluation.ts b/src/domain/process/processTraceEvaluation.ts index 5b61cb1c0b..44c0c05cc7 100644 --- a/src/domain/process/processTraceEvaluation.ts +++ b/src/domain/process/processTraceEvaluation.ts @@ -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; diff --git a/src/domain/process/processTraceSpecification.ts b/src/domain/process/processTraceSpecification.ts index 5c70dc4a2f..79dea64f86 100644 --- a/src/domain/process/processTraceSpecification.ts +++ b/src/domain/process/processTraceSpecification.ts @@ -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; diff --git a/src/domain/reconstructionCoverage.ts b/src/domain/reconstructionCoverage.ts index d2320f38bd..80349b36ac 100644 --- a/src/domain/reconstructionCoverage.ts +++ b/src/domain/reconstructionCoverage.ts @@ -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", diff --git a/src/domain/reconstructionObligationLedgerSchemas.ts b/src/domain/reconstructionObligationLedgerSchemas.ts index 18a9d3abaf..fe1e1b3e6c 100644 --- a/src/domain/reconstructionObligationLedgerSchemas.ts +++ b/src/domain/reconstructionObligationLedgerSchemas.ts @@ -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([ diff --git a/src/domain/reconstructionReadinessSchemas.ts b/src/domain/reconstructionReadinessSchemas.ts index 84be830b73..c32637c767 100644 --- a/src/domain/reconstructionReadinessSchemas.ts +++ b/src/domain/reconstructionReadinessSchemas.ts @@ -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([ diff --git a/src/domain/reconstructionVerificationSchemas.ts b/src/domain/reconstructionVerificationSchemas.ts index 13f5009f5b..0da3a3158c 100644 --- a/src/domain/reconstructionVerificationSchemas.ts +++ b/src/domain/reconstructionVerificationSchemas.ts @@ -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 = { diff --git a/src/domain/referenceSourceGraph.ts b/src/domain/referenceSourceGraph.ts index edc4bc9689..51e614cd3b 100644 --- a/src/domain/referenceSourceGraph.ts +++ b/src/domain/referenceSourceGraph.ts @@ -177,7 +177,7 @@ const graphShape = { caller: z .string() .min(1) - .regex(/^[\w .:@/+-]+$/u), + .regex(/^[\w .:@\x2f+\x2d]+$/u), }), limitations: z.array(boundedTextSchema), }; diff --git a/src/ghidra/extensions/GhidraExtensions.ts b/src/ghidra/extensions/GhidraExtensions.ts index 25007bfa5f..3ca328638e 100644 --- a/src/ghidra/extensions/GhidraExtensions.ts +++ b/src/ghidra/extensions/GhidraExtensions.ts @@ -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() diff --git a/tests/boundary/mcp/advertisedPatternsVMode.test.ts b/tests/boundary/mcp/advertisedPatternsVMode.test.ts new file mode 100644 index 0000000000..abd1ba4ba3 --- /dev/null +++ b/tests/boundary/mcp/advertisedPatternsVMode.test.ts @@ -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([]); +});