Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/adapters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export interface AdapterRequest {
/** Client tool-search names actually lowered to upstream function calls for this request. */
convertedRoutedToolSearchNames?: ReadonlySet<string>;
/** Upstream-only aliases for namespace tools flattened in this request. */
convertedRoutedNamespaceToolAliases?: ReadonlyMap<string, { namespace: string; name: string }>;
convertedRoutedNamespaceToolAliases?: ReadonlyMap<string, { namespace: string; name: string; kind: "function" | "custom" }>;
/** Releases observation of a serialized request body after its final fetch attempt settles. */
releaseBodyObservation?: () => void;
/** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1686,7 +1686,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
let convertedRoutedCustomToolNames: Set<string> | undefined;
let routedCustomToolRepairNames: Set<string> | undefined;
let convertedRoutedToolSearchNames: Set<string> | undefined;
let convertedRoutedNamespaceToolAliases: Map<string, { namespace: string; name: string }> | undefined;
let convertedRoutedNamespaceToolAliases: Map<string, { namespace: string; name: string; kind: "function" | "custom" }> | undefined;
const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true;
let outBody = stripPreviousResponseId(
parsed._rawBody,
Expand Down
64 changes: 62 additions & 2 deletions src/responses/namespace-tool-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import { collectResponsesToolGroups } from "./tool-groups";
export interface RoutedNamespaceToolIdentity {
namespace: string;
name: string;
/**
* The kind the tool was DECLARED as. Restoration and `tool_choice` matching both
* need it: a wire name identifies which tool, not which kind of call may carry it,
* so without this a tool declared `function` could be selected by a `custom`
* selector and come back as a `custom_tool_call` — the same name/kind mismatch
* that motivated narrowing the alias map in the first place.
*/
kind: "function" | "custom";
}

export type RoutedNamespaceToolAliases = ReadonlyMap<string, RoutedNamespaceToolIdentity>;
Expand Down Expand Up @@ -138,7 +146,10 @@ function buildRewritePlan(groups: readonly unknown[][]): NamespaceRewritePlan {
addSelector(selectors, `${parsed.namespace}.${childName}`, wireName);
addSelector(selectors, childName, wireName);
if (parsed.namespace !== BUILTIN_FUNCTIONS_NAMESPACE) {
aliases.set(wireName, { namespace: parsed.namespace, name: childName });
// A child declared `custom` stays custom; everything else lowers to a
// function, which is how `buildTools` flattens it upstream.
const kind = child.type === "custom" ? "custom" : "function";
aliases.set(wireName, { namespace: parsed.namespace, name: childName, kind });
}
}
}
Expand Down Expand Up @@ -239,6 +250,55 @@ 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<string, RoutedNamespaceToolIdentity>,
toolChoice: unknown,
): Map<string, RoutedNamespaceToolIdentity> {
if (toolChoice === undefined || toolChoice === "auto" || toolChoice === "required") return aliases;
if (toolChoice === "none" || !isPlainObject(toolChoice)) return new Map();

// name -> the kind the selector claimed. A selector authorizes a tool only when it
// names it AND agrees about what kind of tool it is.
let authorized: Map<string, "function" | "custom">;
if (
(toolChoice.type === "function" || toolChoice.type === "custom")
&& typeof toolChoice.name === "string"
) {
authorized = new Map([[toolChoice.name, toolChoice.type]]);
} else if (toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) {
authorized = new Map();
for (const tool of toolChoice.tools) {
// Match the top-level branch above: only a function/custom selector can
// authorize a client namespace call. `allowed_tools` entries are typed
// `{type: string}` by the schema, so the accepted set is open-ended and
// an allowlist is the only closure that also covers kinds added later.
// Without this, `{type: "file_search", name: "<wire-name>"}` keeps the
// alias, and an upstream `function_call` carrying that name is restored
// into a namespace call the caller never permitted.
if (!isPlainObject(tool)) continue;
if (tool.type !== "function" && tool.type !== "custom") continue;
if (typeof tool.name !== "string") continue;
authorized.set(tool.name, tool.type);
}
} else {
// An explicit selector for another tool kind does not authorize a client namespace call.
return new Map();
}

// The kind must agree too. A wire name says WHICH tool, not what kind of call may
// carry it, so a `custom` selector naming a tool declared `function` is the same
// name/kind mismatch as a `file_search` selector naming it — narrower, but the
// same class, and `allowed_tools[].type` accepts any string so both are reachable.
return new Map(
[...aliases].filter(([wireName, identity]) => authorized.get(wireName) === identity.kind),
);
}

function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Set<string>): unknown {
if (!isPlainObject(item)) return item;
if (item.type === "additional_tools" && Array.isArray(item.tools)) {
Expand Down Expand Up @@ -292,7 +352,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),
Comment thread
lidge-jun marked this conversation as resolved.
};
}

Expand Down
163 changes: 161 additions & 2 deletions tests/namespace-tool-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe("Responses namespace tool compatibility", () => {
{ type: "custom", name: "exec" },
]);
expect([...rewritten.aliases]).toEqual([
["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent" }],
["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }],
]);
});

Expand Down Expand Up @@ -104,6 +104,165 @@ 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", kind: "function" }],
]);
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);
});

// The authorization boundary is per-request, and `allowed_tools` is where it was
// leaking: entries are typed `{type: string}` by the schema, so the accepted set is
// open-ended, and matching on `name` alone let a selector for a DIFFERENT KIND of
// tool authorize a client namespace function call.
//
// This is not a naming nit. The upstream sees every flattened declaration even when
// `tool_choice` narrows what may be called, so a non-canonical upstream can answer
// with `{type: "function_call", name: "<wire-name>"}`; if the alias survived, the
// restore path rewrites it into `{namespace, name}` and the client executes a tool
// the caller never permitted. The undeclared-tool guard does not catch it either —
// that guard authorizes from the declared catalog, not from `tool_choice`.
describe("allowed_tools authorization is restricted by tool type", () => {
const namespaceTools = [{
type: "namespace",
name: "collaboration",
tools: [{ type: "function", name: "safe" }],
}];
const wireName = "collaboration__safe";

// Every non-function/custom kind the runtime and schema know about, plus an
// unknown future one. The whitelist has to close all of them, including kinds
// nobody has written yet — which is exactly why it is a whitelist.
test.each([
"file_search",
"web_search",
"web_search_preview",
"computer_use",
"computer_use_preview",
"code_interpreter",
"image_generation",
"image_gen",
"mcp",
"tool_search",
"local_shell",
"x_search",
"namespace",
"some_future_tool_kind",
])("a %s entry naming the wire tool authorizes nothing", kind => {
const { aliases } = rewriteRoutedNamespaceToolsForUpstream({
tools: namespaceTools,
tool_choice: { type: "allowed_tools", mode: "required", tools: [{ type: kind, name: wireName }] },
});
expect(aliases.size).toBe(0);
// The map is the whole authorization surface, so an upstream call carrying that
// wire name must stay unrestored rather than becoming a namespaced client call.
const restored = restoreRoutedNamespaceCalls({ type: "function_call", name: wireName }, aliases);
expect(restored.changed).toBe(false);
expect((restored.value as { namespace?: unknown }).namespace).toBeUndefined();
});

test.each(["function", "custom"])("a %s entry authorizes a tool declared that same kind", kind => {
const { aliases } = rewriteRoutedNamespaceToolsForUpstream({
tools: [{ type: "namespace", name: "collaboration", tools: [{ type: kind, name: "safe" }] }],
tool_choice: { type: "allowed_tools", mode: "required", tools: [{ type: kind, name: wireName }] },
});
// Proving the whitelist is not deny-all: without this, a filter that rejected
// everything would pass every test above.
expect(aliases.get(wireName)).toEqual({ namespace: "collaboration", name: "safe", kind });
expect(restoreRoutedNamespaceCalls({ type: "function_call", name: wireName }, aliases).changed).toBe(true);
});

// A wire name says WHICH tool, not what kind of call may carry it. Selecting a
// tool as the wrong kind is the same name/kind mismatch as selecting it with a
// foreign selector — narrower, but the same class, and reachable because
// `allowed_tools[].type` accepts any string.
test.each([
["function", "custom"],
["custom", "function"],
])("a tool declared %s is not authorized by a %s selector", (declared, selected) => {
const build = (choice: unknown) => rewriteRoutedNamespaceToolsForUpstream({
tools: [{ type: "namespace", name: "collaboration", tools: [{ type: declared, name: "safe" }] }],
tool_choice: choice,
}).aliases;

const viaAllowed = build({ type: "allowed_tools", mode: "required", tools: [{ type: selected, name: wireName }] });
expect(viaAllowed.size).toBe(0);
expect(restoreRoutedNamespaceCalls({ type: "function_call", name: wireName }, viaAllowed).changed).toBe(false);

// The forced-selector branch has to agree, or the narrowing only holds for
// one of the two shapes a caller can write.
const viaForced = build({ type: selected, name: wireName });
expect(viaForced.size).toBe(0);
});

test("a foreign entry cannot ride alongside an authorized one", () => {
const { aliases } = rewriteRoutedNamespaceToolsForUpstream({
tools: [{
type: "namespace",
name: "collaboration",
tools: [{ type: "function", name: "safe" }, { type: "function", name: "excluded" }],
}],
tool_choice: {
type: "allowed_tools",
mode: "required",
tools: [
{ type: "function", name: wireName },
{ type: "file_search", name: "collaboration__excluded" },
],
},
});
expect([...aliases.keys()]).toEqual([wireName]);
});
});

test("default and absent tool_choice keep every alias", () => {
const tools = [{
type: "namespace",
name: "collaboration",
tools: [{ type: "function", name: "safe" }, { type: "function", name: "other" }],
}];
// Narrowing only applies when the caller actually narrowed. These three are the
// "no restriction stated" cases and must not be collapsed by the filter.
for (const choice of [undefined, "auto", "required"]) {
const { aliases } = rewriteRoutedNamespaceToolsForUpstream(
choice === undefined ? { tools } : { tools, tool_choice: choice },
);
expect(aliases.size).toBe(2);
}
// A top-level selector for another tool kind states a restriction that no
// namespace call satisfies, so it authorizes nothing.
expect(rewriteRoutedNamespaceToolsForUpstream({
tools,
tool_choice: { type: "file_search" },
}).aliases.size).toBe(0);
});

test("fails closed when flattening would collide with a declared wire name", () => {
expect(() => rewriteRoutedNamespaceToolsForUpstream({
tools: [
Expand Down Expand Up @@ -238,7 +397,7 @@ describe("Responses namespace tool compatibility", () => {

test("restores only aliases authorized by this request in JSON and SSE payloads", () => {
const aliases = new Map([
["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent" }],
["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }],
]);
const payload = {
type: "response.completed",
Expand Down
2 changes: 1 addition & 1 deletion tests/openai-responses-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ describe("routed compaction lowering order", () => {
expect([...(built.convertedRoutedCustomToolNames ?? [])]).toEqual(["apply_patch"]);
expect([...(built.convertedRoutedToolSearchNames ?? [])]).toEqual(["opencodex_tool_search"]);
expect([...(built.convertedRoutedNamespaceToolAliases ?? new Map()).entries()]).toEqual([
["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent" }],
["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }],
]);
});

Expand Down
Loading