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:
1 parent
99f257bbf0
commit
a8701eaf48
4 files changed
+113
-3
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
|
||||
Reference in new issue
Block a user