fix(tool-search): scope bridge catalog + dispatch to the session's toolsets
Tool Search read its catalog from the global registry (get_tool_definitions
with no toolset scope = 'start with everything'), so a restricted-toolset
session — subagent, kanban worker, curated gateway session — could:
1. tool_search the entire process registry, not just its granted tools, and
2. tool_call any registered plugin/MCP tool it was never given, because
registry.dispatch() has no enabled_tools gate for non-execute_code tools.
A scoped session (enabled_toolsets=['mcp-github']) reported total_available=26
and successfully invoked an out-of-scope plugin tool via tool_call.
Fix:
- handle_function_call gains enabled_toolsets/disabled_toolsets; the bridge
dispatch scopes get_tool_definitions to them (also stops polluting the
process-global _last_resolved_tool_names with out-of-scope tools, which
leaked into execute_code's sandbox-tool fallback).
- A defense-in-depth gate rejects any tool_call'd name not in the scoped
deferrable catalog.
- tool_executor's unwrap (both concurrent + sequential paths) enforces the
same scope before dispatch, since it unwraps tool_call -> underlying name
and bypasses the bridge branch. New _tool_search_scoped_names() helper,
cached per-agent on registry generation + toolset scope.
- New scoped_deferrable_names() helper in tool_search.py shared by both sites.
Tests: 4 new regression tests in TestRegression_ToolsetScoping (scoped
catalog, out-of-scope tool_call rejection, no global pollution, helper).
This commit is contained in:
@@ -657,6 +657,26 @@ def dispatch_tool_describe(args: Dict[str, Any],
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
def scoped_deferrable_names(tool_defs: List[Dict[str, Any]]) -> frozenset[str]:
|
||||
"""Return the set of deferrable tool names present in ``tool_defs``.
|
||||
|
||||
``tool_defs`` is expected to be the *pre-assembly* tool list for the
|
||||
current session's toolset scope (i.e. what
|
||||
``get_tool_definitions(skip_tool_search_assembly=True)`` returns for the
|
||||
session's enabled/disabled toolsets). The resulting set is the universe of
|
||||
tools the session may legitimately reach through ``tool_call``. Used as a
|
||||
scoping gate by both the ``model_tools`` bridge dispatch and the
|
||||
``tool_executor`` unwrap so a restricted-toolset session can never invoke
|
||||
an out-of-scope tool via the bridge.
|
||||
"""
|
||||
names: set[str] = set()
|
||||
for td in tool_defs:
|
||||
name = (td.get("function") or {}).get("name", "")
|
||||
if name and is_deferrable_tool_name(name):
|
||||
names.add(name)
|
||||
return frozenset(names)
|
||||
|
||||
|
||||
def resolve_underlying_call(args: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any], Optional[str]]:
|
||||
"""Parse a ``tool_call`` invocation into (underlying_name, args, error_msg).
|
||||
|
||||
@@ -711,4 +731,5 @@ __all__ = [
|
||||
"dispatch_tool_search",
|
||||
"dispatch_tool_describe",
|
||||
"resolve_underlying_call",
|
||||
"scoped_deferrable_names",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user