From 0b485d294ae60dc0875a1b9f8889ecafcfcdd270 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sun, 23 Aug 2026 10:31:01 +0900 Subject: [PATCH] fix(responses): honor namespace tool choice --- src/responses/namespace-tool-compat.ts | 34 +++++++++++++++++++++++++- tests/namespace-tool-compat.test.ts | 33 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index cbc90db605..7bd168b5b5 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -239,6 +239,38 @@ function rewriteToolChoice(value: unknown, plan: NamespaceRewritePlan): unknown return changed ? { ...value, tools } : value; } +/** + * Keep response restoration inside the caller's per-turn tool authorization boundary. The + * upstream sees every flattened declaration even when `tool_choice` narrows the tools it may call, + * so its output cannot be trusted merely because a wire name appeared in that catalog. + */ +function authorizedAliases( + aliases: Map, + toolChoice: unknown, +): Map { + if (toolChoice === undefined || toolChoice === "auto" || toolChoice === "required") return aliases; + if (toolChoice === "none" || !isPlainObject(toolChoice)) return new Map(); + + let authorizedNames: Set; + if ( + (toolChoice.type === "function" || toolChoice.type === "custom") + && typeof toolChoice.name === "string" + ) { + authorizedNames = new Set([toolChoice.name]); + } else if (toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) { + authorizedNames = new Set( + toolChoice.tools + .filter(tool => isPlainObject(tool) && typeof tool.name === "string") + .map(tool => tool.name as string), + ); + } else { + // An explicit selector for another tool kind does not authorize a client namespace call. + return new Map(); + } + + return new Map([...aliases].filter(([wireName]) => authorizedNames.has(wireName))); +} + function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Set): unknown { if (!isPlainObject(item)) return item; if (item.type === "additional_tools" && Array.isArray(item.tools)) { @@ -292,7 +324,7 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { ...(input !== body.input ? { input } : {}), ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}), }, - aliases: plan.aliases, + aliases: authorizedAliases(plan.aliases, toolChoice), }; } diff --git a/tests/namespace-tool-compat.test.ts b/tests/namespace-tool-compat.test.ts index 83367a8ed8..0423498454 100644 --- a/tests/namespace-tool-compat.test.ts +++ b/tests/namespace-tool-compat.test.ts @@ -104,6 +104,39 @@ describe("Responses namespace tool compatibility", () => { expect(directCollision.tool_choice.name).toBe("read"); }); + test("only arms response aliases authorized by tool_choice", () => { + const tools = [{ + type: "namespace", + name: "collaboration", + tools: [ + { type: "function", name: "safe" }, + { type: "function", name: "excluded" }, + ], + }]; + + const allowed = rewriteRoutedNamespaceToolsForUpstream({ + tools, + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "function", namespace: "collaboration", name: "safe" }], + }, + }); + expect([...allowed.aliases]).toEqual([ + ["collaboration__safe", { namespace: "collaboration", name: "safe" }], + ]); + expect(restoreRoutedNamespaceCalls({ + type: "function_call", + name: "collaboration__excluded", + }, allowed.aliases).changed).toBe(false); + + expect(rewriteRoutedNamespaceToolsForUpstream({ + tools, + tool_choice: { type: "function", namespace: "collaboration", name: "safe" }, + }).aliases.has("collaboration__excluded")).toBe(false); + expect(rewriteRoutedNamespaceToolsForUpstream({ tools, tool_choice: "none" }).aliases.size).toBe(0); + }); + test("fails closed when flattening would collide with a declared wire name", () => { expect(() => rewriteRoutedNamespaceToolsForUpstream({ tools: [