fix(mcp): advertise input patterns without look-around

trace_dylib_resolution roots and capture_process_scenario environment
names still advertised negative look-ahead patterns. RE2 and Rust schema
validators reject look-around, so a client using one refuses the whole
tools request, as #1282 reported for the NUL escape. Spell both
constraints as equivalent look-around-free patterns and assert that no
advertised pattern uses look-around or backreferences.

Closes #1282

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-09 14:49:22 +00:00
1 parent 99f257bbf0
commit a8701eaf48
4 files changed
+113 -3

No files matched your search

+6 -2
View File
@@ -19,9 +19,13 @@ import {
deriveFindings,
} from "./dylibResolutionFindings.js";
/** Normalized path below the analyzed root: no `.`/`..` or empty segments. */
/**
* Normalized path below the analyzed root: nonempty segments joined by single
* `/`, none of them `.` or `..`, without backslash or NUL. Advertised schema
* patterns avoid look-around, which RE2 and Rust validators reject.
*/
const ROOT_RELATIVE_PATH =
/^(?!\/)(?!(?:.*\/)?\.{1,2}(?:\/|$))(?!.*\/\/)(?!.*\/$)[^\\\u0000]+$/u;
/^(?:[^\x2f\\\u0000.][^\x2f\\\u0000]*|\.[^\x2f\\\u0000.][^\x2f\\\u0000]*|\.\.[^\x2f\\\u0000]+)(?:\x2f(?:[^\x2f\\\u0000.][^\x2f\\\u0000]*|\.[^\x2f\\\u0000.][^\x2f\\\u0000]*|\.\.[^\x2f\\\u0000]+))*$/u;
const rootRelativePathSchema = z
.string()
.min(1)
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { dylibResolutionInputSchema } from "./apple/dylibResolution.js";
import { processScenarioSchema } from "./process/processScenario.js";
// The look-ahead spellings these schemas advertised before; RE2 and Rust
// validators reject look-around, so the schemas now use equivalent complements.
const lookaheadRootRelativePath =
/^(?!\/)(?!(?:.*\/)?\.{1,2}(?:\/|$))(?!.*\/\/)(?!.*\/$)[^\\\u0000]+$/u;
const lookaheadEnvironmentName =
/^(?!REA_PROCESS_RUN_ID(?![\s\S]))[^=\u0000]+$/u;
const strings = (alphabet: readonly string[], maxLength: number): string[] => {
const all: string[] = [];
let level = [""];
for (let length = 1; length <= maxLength; length += 1) {
level = level.flatMap((prefix) =>
alphabet.map((character) => `${prefix}${character}`),
);
all.push(...level);
}
return all;
};
describe("look-around-free advertised patterns", () => {
it("accepts exactly the previous normalized relative dylib roots", () => {
const candidates = strings(["a", ".", "/", "\\", "\u0000"], 7);
const mismatches = candidates.filter(
(root) =>
dylibResolutionInputSchema.safeParse({ roots: [root] }).success !==
lookaheadRootRelativePath.test(root),
);
expect(candidates.length).toBeGreaterThan(90_000);
expect(mismatches).toEqual([]);
});
it("accepts exactly the previous environment names around the reserved name", () => {
const reserved = "REA_PROCESS_RUN_ID";
const edits = ["", "R", "X", "_", "=", "\u0000", "é"];
const candidates = new Set<string>(strings(["R", "E", "=", "x"], 5));
for (let index = 0; index <= reserved.length; index += 1)
for (const edit of edits) {
const prefix = reserved.slice(0, index);
candidates.add(`${prefix}${edit}`);
candidates.add(`${prefix}${edit}${reserved.slice(index)}`);
candidates.add(`${prefix}${edit}${reserved.slice(index + 1)}`);
candidates.add(`${edit}${reserved.slice(index)}`);
}
const accepts = (name: string) =>
processScenarioSchema.safeParse({
executable: "/bin/true",
environment: { [name]: "value" },
}).success;
const mismatches = [...candidates].filter(
(name) => accepts(name) !== lookaheadEnvironmentName.test(name),
);
expect(accepts(reserved)).toBe(false);
expect(accepts(`${reserved}_2`)).toBe(true);
expect(accepts("REA_PROCESS_RUN")).toBe(true);
expect(mismatches).toEqual([]);
});
it("keeps the reserved-name diagnostic", () => {
const parsed = processScenarioSchema.safeParse({
executable: "/bin/true",
environment: { REA_PROCESS_RUN_ID: "value" },
});
// Record keys report their own issues nested under the invalid key.
expect(parsed.error?.issues).toMatchObject([
{
code: "invalid_key",
path: ["environment", "REA_PROCESS_RUN_ID"],
issues: [
{ message: "REA_PROCESS_RUN_ID is reserved by the process adapter" },
],
},
]);
});
});
+22 -1
View File
@@ -5,10 +5,31 @@ 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]";
/**
* Nonempty environment names other than the reserved run identifier. The
* exclusion is spelled as a complement instead of a negative look-ahead,
* because RE2 and Rust schema validators reject look-around and would refuse
* every tool in the advertised catalog.
*/
const environmentNamePattern = (reserved: string): RegExp => {
// After the complete reserved name, at least one more character is needed.
let rest = `${ENVIRONMENT_NAME_CHARACTER}+`;
for (let index = reserved.length - 1; index >= 0; index -= 1) {
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}`;
rest = index === 0 ? choice : `(?:${choice})?`;
}
return new RegExp(`^(?:${rest})$`, "u");
};
const environmentName = z
.string()
.min(1, "Environment names must not be empty")
.regex(/^(?!REA_PROCESS_RUN_ID(?![\s\S]))[^=\u0000]+$/u, {
.regex(environmentNamePattern(reservedRunIdEnvironmentName), {
error: (issue) =>
issue.input === reservedRunIdEnvironmentName
? `${reservedRunIdEnvironmentName} is reserved by the process adapter`
@@ -77,6 +77,12 @@ it("advertises portable NUL escapes and patterns that compile in all JS modes",
);
expect(uncompilable).toEqual([]);
// RE2 and Rust validators reject look-around and backreferences, and like
// the NUL escape below, one such pattern refuses the complete tools request.
expect(
patterns.filter(({ pattern }) => /\(\?<?[=!]|\\[1-9]|\\k</u.test(pattern)),
).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([]);