Compare commits

...

1 Commits

Author SHA1 Message Date
abeatrix d09cf5dd4b fix: validate plugin tool input schemas for portability
Validate registered plugin tool input schemas in both the sandbox bootstrap and shared contribution registry. Reject non-portable JSON schema patterns, such as regex lookarounds, with extension and tool context to prevent runtime compatibility issues.

Add coverage for raw tool registrations that use unsupported regex patterns.
2026-05-22 15:09:22 -07:00
8 changed files with 216 additions and 1 deletions
@@ -12,6 +12,7 @@
import {
type AutomationEventEnvelope,
assertToolInputSchemaPortable,
normalizePluginManifest,
type PluginManifest,
} from "@cline/shared";
@@ -398,6 +399,7 @@ async function loadPluginDescriptor(args: {
moduleExports[args.exportName]) as unknown as PluginModule;
assertValidPluginModule(plugin, args.pluginPath);
plugin.manifest = normalizePluginManifest(plugin.manifest);
const pluginName = plugin.name;
if (!matchesPluginManifestTargeting(plugin.manifest, args.targeting)) {
return { type: "skipped" };
}
@@ -419,6 +421,10 @@ async function loadPluginDescriptor(args: {
const api: PluginApi = {
registerTool: (tool) => {
assertToolInputSchemaPortable(tool.inputSchema, {
extensionName: pluginName,
toolName: tool.name,
});
const id = makeId(args.pluginId, "tool");
handlers.tools.set(id, tool.execute);
contributions.tools.push({
@@ -133,3 +133,38 @@ describe("ContributionRegistry automation event contributions", () => {
);
});
});
describe("ContributionRegistry tool contributions", () => {
it("rejects raw plugin tool schemas with regex lookaround patterns", async () => {
const registry = createContributionRegistry({
extensions: [
{
name: "raw-tool-plugin",
manifest: { capabilities: ["tools"] },
setup(api) {
api.registerTool({
name: "save_handoff",
description: "Save a handoff file",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
pattern:
"^(?!\\/)(?!.*(?:^|\\/)\\.\\.(?:\\/|$))[A-Za-z0-9._/-]+$",
},
},
required: ["path"],
},
execute: async () => ({ ok: true }),
});
},
},
],
});
await expect(registry.initialize()).rejects.toThrow(
/raw-tool-plugin\.save_handoff.*\$\.properties\.path\.pattern.*lookaround/i,
);
});
});
@@ -3,6 +3,7 @@ import type { AutomationEventEnvelope } from "../cron";
import type { BasicLogger } from "../logging/logger";
import type { ITelemetryService } from "../services/telemetry";
import type { WorkspaceInfo } from "../session/workspace";
import { assertToolInputSchemaPortable } from "../tools/schema-compat";
import type { ClientContext, UserContext } from "./context";
export interface AgentExtensionCommand {
@@ -359,6 +360,23 @@ function normalizeAutomationEventType(
};
}
function validateRegisteredToolInputSchema<TTool>(
tool: TTool,
extensionName: string,
): void {
if (!tool || typeof tool !== "object") {
return;
}
const record = tool as Record<string, unknown>;
if (!Object.hasOwn(record, "inputSchema")) {
return;
}
assertToolInputSchemaPortable(record.inputSchema, {
extensionName,
toolName: typeof record.name === "string" ? record.name : undefined,
});
}
export class ContributionRegistry<
TExtension extends ContributionRegistryExtension<TTool, TMessage>,
TTool = AgentTool,
@@ -421,7 +439,10 @@ export class ContributionRegistry<
if (extension.disabled) continue;
const extensionName = asExtensionName(extension, entry.order);
const api: AgentExtensionApi<TTool, TMessage> = {
registerTool: (tool) => this.registry.tools.push(tool),
registerTool: (tool) => {
validateRegisteredToolInputSchema(tool, extensionName);
this.registry.tools.push(tool);
},
registerCommand: (command) => this.registry.commands.push(command),
registerRule: (rule) => {
if (!entry.manifest.capabilities.has("rules")) {
+6
View File
@@ -339,6 +339,12 @@ export type { RuntimeEnv } from "./session/runtime-env";
export * from "./session/workspace";
export * from "./team";
export { createTool } from "./tools/create";
export {
assertToolInputSchemaPortable,
collectToolInputSchemaCompatibilityIssues,
type ToolInputSchemaCompatibilityContext,
type ToolInputSchemaCompatibilityIssue,
} from "./tools/schema-compat";
export type { OAuthProviderId } from "./types/auth";
export {
AUTH_ERROR_PATTERNS,
+6
View File
@@ -389,6 +389,12 @@ export type { RuntimeEnv } from "./session/runtime-env";
export * from "./session/workspace";
export * from "./team";
export { createTool } from "./tools/create";
export {
assertToolInputSchemaPortable,
collectToolInputSchemaCompatibilityIssues,
type ToolInputSchemaCompatibilityContext,
type ToolInputSchemaCompatibilityIssue,
} from "./tools/schema-compat";
export * from "./types";
export type { OAuthProviderId } from "./types/auth";
export {
@@ -112,6 +112,27 @@ describe("createTool", () => {
).toThrow(/top level/i);
});
it("throws when inputSchema contains regex lookaround patterns", () => {
expect(() =>
createTool({
name: "lookaround_schema_tool",
description: "Tool with a provider-incompatible path pattern",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
pattern:
"^(?!\\/)(?!.*(?:^|\\/)\\.\\.(?:\\/|$))[A-Za-z0-9._/-]+$",
},
},
required: ["path"],
},
execute: async () => ({ ok: true }),
}),
).toThrow(/\$\.properties\.path\.pattern.*lookaround/i);
});
it("infers type:object for allOf when at least one branch has type:object", () => {
// The canonical allOf case: one branch sets type + properties, another
// adds required without repeating type: "object". The current input is
+2
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { AgentTool, AgentToolContext } from "../agent";
import { zodToJsonSchema } from "../parse/zod";
import { assertToolInputSchemaPortable } from "./schema-compat";
function normalizeToolInputSchema(
inputSchema: Record<string, unknown>,
@@ -116,6 +117,7 @@ export function createTool<TInput, TOutput>(config: {
? zodToJsonSchema(config.inputSchema)
: config.inputSchema,
);
assertToolInputSchemaPortable(inputSchema, { toolName: config.name });
return {
name: config.name,
@@ -0,0 +1,118 @@
export interface ToolInputSchemaCompatibilityIssue {
path: string;
message: string;
value?: string;
}
export interface ToolInputSchemaCompatibilityContext {
extensionName?: string;
toolName?: string;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function formatPathSegment(key: string): string {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
? `.${key}`
: `[${JSON.stringify(key)}]`;
}
function isEscaped(input: string, index: number): boolean {
let backslashes = 0;
for (let i = index - 1; i >= 0 && input[i] === "\\"; i--) {
backslashes++;
}
return backslashes % 2 === 1;
}
function containsRegexLookaround(pattern: string): boolean {
for (let index = 0; index < pattern.length - 2; index++) {
if (
pattern[index] !== "(" ||
pattern[index + 1] !== "?" ||
isEscaped(pattern, index)
) {
continue;
}
const marker = pattern[index + 2];
if (marker === "=" || marker === "!") {
return true;
}
if (
marker === "<" &&
(pattern[index + 3] === "=" || pattern[index + 3] === "!")
) {
return true;
}
}
return false;
}
export function collectToolInputSchemaCompatibilityIssues(
schema: unknown,
): ToolInputSchemaCompatibilityIssue[] {
const issues: ToolInputSchemaCompatibilityIssue[] = [];
function visit(value: unknown, path: string): void {
if (Array.isArray(value)) {
value.forEach((entry, index) => {
visit(entry, `${path}[${index}]`);
});
return;
}
if (!isObject(value)) {
return;
}
for (const [key, entry] of Object.entries(value)) {
const entryPath = `${path}${formatPathSegment(key)}`;
if (
key === "pattern" &&
typeof entry === "string" &&
containsRegexLookaround(entry)
) {
issues.push({
path: entryPath,
message:
"regex lookaround is not supported in portable tool JSON schemas",
value: entry,
});
continue;
}
visit(entry, entryPath);
}
}
visit(schema, "$");
return issues;
}
export function assertToolInputSchemaPortable(
schema: unknown,
context: ToolInputSchemaCompatibilityContext = {},
): void {
const issues = collectToolInputSchemaCompatibilityIssues(schema);
if (issues.length === 0) {
return;
}
const subject =
context.toolName && context.extensionName
? `Tool inputSchema for "${context.extensionName}.${context.toolName}"`
: context.toolName
? `Tool inputSchema for "${context.toolName}"`
: "Tool inputSchema";
const details = issues
.map((issue) => {
const value = issue.value ? ` (${JSON.stringify(issue.value)})` : "";
return `${issue.path}: ${issue.message}${value}`;
})
.join("; ");
throw new Error(
`${subject} contains provider-incompatible JSON Schema: ${details}. ` +
"Move this validation into the tool execute function or use a simpler provider-compatible pattern.",
);
}