fix(tools): reject malformed availability expressions (#92411)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
pick-cat
2026-07-11 20:32:22 +08:00
committed by GitHub
parent 3ed8143495
commit 0ca6880f3d
2 changed files with 144 additions and 4 deletions
+74
View File
@@ -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<string, unknown> {
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 }]);
});
});
+70 -4
View File
@@ -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<string, unknown>,
): value is Record<string, unknown> & 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<object>,
): 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<string, unknown>;
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",