fix: isolate malformed plugin tools

This commit is contained in:
Peter Steinberger
2026-04-27 13:22:23 +01:00
parent 7fb2a356e8
commit 0a076bc0fc
6 changed files with 154 additions and 13 deletions

View File

@@ -98,6 +98,17 @@ function createOptionalDemoEntry(): MockRegistryToolEntry {
};
}
function createMalformedTool(name: string) {
return {
name,
description: `${name} tool`,
inputSchema: { type: "object", properties: {} },
async execute() {
return { content: [{ type: "text", text: "bad" }] };
},
};
}
function resolveWithConflictingCoreName(options?: { suppressNameConflicts?: boolean }) {
return resolvePluginTools(
createResolveToolsParams({
@@ -310,6 +321,44 @@ describe("resolvePluginTools optional tools", () => {
expectLoaderCall(expectedLoaderCall);
});
it("skips malformed plugin tools while keeping valid sibling tools", () => {
const registry = setRegistry([
{
pluginId: "schema-bug",
optional: false,
source: "/tmp/schema-bug.js",
factory: () => [createMalformedTool("broken_tool"), makeTool("valid_tool")],
},
]);
const tools = resolvePluginTools(createResolveToolsParams());
expectResolvedToolNames(tools, ["valid_tool"]);
expectSingleDiagnosticMessage(
registry.diagnostics,
"plugin tool is malformed (schema-bug): broken_tool missing parameters object",
);
});
it("skips allowlisted optional malformed plugin tools", () => {
const registry = setRegistry([
{
pluginId: "optional-demo",
optional: true,
source: "/tmp/optional-demo.js",
factory: () => createMalformedTool("optional_tool"),
},
]);
const tools = resolveOptionalDemoTools(["optional_tool"]);
expect(tools).toHaveLength(0);
expectSingleDiagnosticMessage(
registry.diagnostics,
"plugin tool is malformed (optional-demo): optional_tool missing parameters object",
);
});
it.each([
{
name: "loads plugin tools from the auto-enabled config snapshot",

View File

@@ -58,6 +58,34 @@ function isOptionalToolAllowed(params: {
return params.allowlist.has("group:plugins");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function readPluginToolName(tool: unknown): string {
if (!isRecord(tool)) {
return "";
}
return typeof tool.name === "string" ? tool.name.trim() : "";
}
function describeMalformedPluginTool(tool: unknown): string | undefined {
if (!isRecord(tool)) {
return "tool must be an object";
}
const name = readPluginToolName(tool);
if (!name) {
return "missing non-empty name";
}
if (typeof tool.execute !== "function") {
return `${name} missing execute function`;
}
if (!isRecord(tool.parameters)) {
return `${name} missing parameters object`;
}
return undefined;
}
function resolvePluginToolRegistry(params: {
loadOptions: PluginLoadOptions;
allowGatewaySubagentBinding?: boolean;
@@ -146,11 +174,11 @@ export function resolvePluginTools(params: {
}
continue;
}
const listRaw = Array.isArray(resolved) ? resolved : [resolved];
const listRaw: unknown[] = Array.isArray(resolved) ? resolved : [resolved];
const list = entry.optional
? listRaw.filter((tool) =>
isOptionalToolAllowed({
toolName: tool.name,
toolName: readPluginToolName(tool),
pluginId: entry.pluginId,
allowlist,
}),
@@ -160,7 +188,20 @@ export function resolvePluginTools(params: {
continue;
}
const nameSet = new Set<string>();
for (const tool of list) {
for (const toolRaw of list) {
const malformedReason = describeMalformedPluginTool(toolRaw);
if (malformedReason) {
const message = `plugin tool is malformed (${entry.pluginId}): ${malformedReason}`;
context.logger.error(message);
registry.diagnostics.push({
level: "error",
pluginId: entry.pluginId,
source: entry.source,
message,
});
continue;
}
const tool = toolRaw as AnyAgentTool;
if (nameSet.has(tool.name) || existing.has(tool.name)) {
const message = `plugin tool name conflict (${entry.pluginId}): ${tool.name}`;
if (!params.suppressNameConflicts) {