From 0ca6880f3d92d122f10eb80f6b3c46a16cdba6de Mon Sep 17 00:00:00 2001 From: pick-cat Date: Sat, 11 Jul 2026 20:32:22 +0800 Subject: [PATCH] fix(tools): reject malformed availability expressions (#92411) Co-authored-by: Peter Steinberger --- src/tools/availability.test.ts | 74 ++++++++++++++++++++++++++++++++++ src/tools/availability.ts | 74 ++++++++++++++++++++++++++++++++-- 2 files changed, 144 insertions(+), 4 deletions(-) diff --git a/src/tools/availability.test.ts b/src/tools/availability.test.ts index ced3c16f7400..78cd519c94af 100644 --- a/src/tools/availability.test.ts +++ b/src/tools/availability.test.ts @@ -11,6 +11,16 @@ const baseDescriptor: ToolDescriptor = { executor: { kind: "core", executorId: "example" }, }; +function descriptorWithAvailability(availability: unknown): ToolDescriptor { + return { ...baseDescriptor, availability } as ToolDescriptor; +} + +function sparseArray(): unknown[] { + const values: unknown[] = []; + values.length = 1; + return values; +} + function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } @@ -255,4 +265,68 @@ describe("evaluateToolAvailability", () => { }).map((entry) => entry.reason), ).toEqual(["unsupported-signal"]); }); + + it.each([ + null, + "invalid", + [], + { kind: "auth" }, + { kind: "config", path: "plugins.demo" }, + { kind: "config", path: [1] }, + { kind: "config", path: [], check: "invalid" }, + { kind: "context", key: "", equals: {} }, + { allOf: "invalid" }, + { anyOf: [null] }, + { allOf: [], anyOf: [] }, + { allOf: sparseArray() }, + { anyOf: sparseArray() }, + { kind: "config", path: sparseArray() }, + ])("rejects malformed availability without throwing: %j", (availability) => { + expect( + evaluateToolAvailability({ descriptor: descriptorWithAvailability(availability) }), + ).toStrictEqual([ + { + reason: "unsupported-signal", + message: "Unsupported availability expression", + }, + ]); + }); + + it("rejects cyclic availability expressions without overflowing", () => { + const availability: { allOf: unknown[] } = { allOf: [] }; + availability.allOf.push(availability); + + expect( + evaluateToolAvailability({ descriptor: descriptorWithAvailability(availability) }), + ).toStrictEqual([ + { + reason: "unsupported-signal", + message: "Unsupported availability expression", + }, + ]); + }); + + it("allows one availability expression to be shared between sibling branches", () => { + const signal = { kind: "auth", providerId: "openai" } as const; + const descriptor: ToolDescriptor = { + ...baseDescriptor, + availability: { allOf: [signal, signal] }, + }; + + expect( + evaluateToolAvailability({ + descriptor, + context: { authProviderIds: new Set(["openai"]) }, + }), + ).toStrictEqual([]); + }); + + it.each([ + [{ allOf: [] }, "Empty availability allOf group"], + [{ anyOf: [] }, "Empty availability anyOf group"], + ] as const)("preserves precise empty-group diagnostics", (availability, message) => { + expect( + evaluateToolAvailability({ descriptor: descriptorWithAvailability(availability) }), + ).toStrictEqual([{ reason: "unsupported-signal", message }]); + }); }); diff --git a/src/tools/availability.ts b/src/tools/availability.ts index b2c2c4d59c82..5600a970975e 100644 --- a/src/tools/availability.ts +++ b/src/tools/availability.ts @@ -68,8 +68,71 @@ function hasConfiguredValue(params: { return true; } -function hasAvailabilityExpressionShape(value: ToolAvailabilityExpression): boolean { - return "kind" in value || "allOf" in value || "anyOf" in value; +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isJsonPrimitive(value: unknown): value is JsonPrimitive { + return value === null || ["string", "number", "boolean"].includes(typeof value); +} + +function isStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && Array.from(value).every((entry) => typeof entry === "string"); +} + +function isAvailabilitySignal( + value: Record, +): value is Record & ToolAvailabilitySignal { + switch (value.kind) { + case "always": + return true; + case "auth": + return isNonEmptyString(value.providerId); + case "config": + return ( + isStringArray(value.path) && + (value.check === undefined || + value.check === "exists" || + value.check === "non-empty" || + value.check === "available") + ); + case "env": + return isNonEmptyString(value.name); + case "plugin-enabled": + return isNonEmptyString(value.pluginId); + case "context": + return isNonEmptyString(value.key) && (!("equals" in value) || isJsonPrimitive(value.equals)); + default: + return false; + } +} + +function isAvailabilityExpression( + value: unknown, + active: WeakSet, +): value is ToolAvailabilityExpression { + if (!value || typeof value !== "object" || Array.isArray(value) || active.has(value)) { + return false; + } + active.add(value); + try { + const expression = value as Record; + const shapeCount = + Number("kind" in expression) + Number("allOf" in expression) + Number("anyOf" in expression); + if (shapeCount !== 1) { + return false; + } + if ("kind" in expression) { + return isAvailabilitySignal(expression); + } + const entries = "allOf" in expression ? expression.allOf : expression.anyOf; + return ( + Array.isArray(entries) && + Array.from(entries).every((entry) => isAvailabilityExpression(entry, active)) + ); + } finally { + active.delete(value); + } } function diagnostic( @@ -172,8 +235,11 @@ export function evaluateToolAvailability(params: { context?: ToolAvailabilityContext; }): readonly ToolAvailabilityDiagnostic[] { const context = params.context ?? {}; - const availability = params.descriptor.availability ?? { kind: "always" }; - if (!hasAvailabilityExpressionShape(availability)) { + const availability = params.descriptor.availability; + if (availability === undefined) { + return []; + } + if (!isAvailabilityExpression(availability, new WeakSet())) { return [ { reason: "unsupported-signal",