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:
@ -262,8 +262,8 @@ def _clear_tool_defs_cache() -> None:
|
||||
|
||||
|
||||
def get_tool_definitions(
|
||||
enabled_toolsets: List[str] = None,
|
||||
disabled_toolsets: List[str] = None,
|
||||
enabled_toolsets: Optional[List[str]] = None,
|
||||
disabled_toolsets: Optional[List[str]] = None,
|
||||
quiet_mode: bool = False,
|
||||
skip_tool_search_assembly: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
@ -335,8 +335,8 @@ def get_tool_definitions(
|
||||
|
||||
|
||||
def _compute_tool_definitions(
|
||||
enabled_toolsets: List[str] = None,
|
||||
disabled_toolsets: List[str] = None,
|
||||
enabled_toolsets: Optional[List[str]] = None,
|
||||
disabled_toolsets: Optional[List[str]] = None,
|
||||
quiet_mode: bool = False,
|
||||
skip_tool_search_assembly: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
@ -808,6 +808,8 @@ def handle_function_call(
|
||||
user_task: Optional[str] = None,
|
||||
enabled_tools: Optional[List[str]] = None,
|
||||
skip_pre_tool_call_hook: bool = False,
|
||||
enabled_toolsets: Optional[List[str]] = None,
|
||||
disabled_toolsets: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Main function call dispatcher that routes calls to the tool registry.
|
||||
@ -821,6 +823,14 @@ def handle_function_call(
|
||||
execute_code uses this list to determine which sandbox
|
||||
tools to generate. Falls back to the process-global
|
||||
``_last_resolved_tool_names`` for backward compat.
|
||||
enabled_toolsets: The session's enabled toolsets. Used to scope the
|
||||
Tool Search bridge catalog so ``tool_search`` /
|
||||
``tool_describe`` / ``tool_call`` only see and invoke
|
||||
tools the session was actually granted. ``None`` means
|
||||
"no restriction" (the caller scopes to every toolset),
|
||||
matching ``get_tool_definitions`` semantics.
|
||||
disabled_toolsets: The session's disabled toolsets, applied as a
|
||||
subtraction when scoping the bridge catalog.
|
||||
|
||||
Returns:
|
||||
Function result as a JSON string.
|
||||
@ -844,7 +854,19 @@ def handle_function_call(
|
||||
# Use skip_tool_search_assembly=True so we see the real catalog,
|
||||
# not the already-collapsed bridge-only list (the bridge would
|
||||
# otherwise be searching only itself).
|
||||
#
|
||||
# Scope the catalog to the session's toolsets so the bridge can
|
||||
# only surface and invoke tools the session was actually granted.
|
||||
# Without this, a restricted-toolset session (subagent, kanban
|
||||
# worker, curated gateway session) would see and be able to call
|
||||
# the entire process registry via the bridge. Passing the same
|
||||
# enabled/disabled toolsets the session was assembled with keeps
|
||||
# the deferred catalog identical to the deferrable subset of the
|
||||
# session's own tool list, and avoids polluting the process-global
|
||||
# _last_resolved_tool_names with out-of-scope tools.
|
||||
current_defs = get_tool_definitions(
|
||||
enabled_toolsets=enabled_toolsets,
|
||||
disabled_toolsets=disabled_toolsets,
|
||||
quiet_mode=True, skip_tool_search_assembly=True,
|
||||
) or []
|
||||
except Exception:
|
||||
@ -860,6 +882,20 @@ def handle_function_call(
|
||||
if err or not underlying_name:
|
||||
return json.dumps({"error": err or "tool_call could not be resolved"},
|
||||
ensure_ascii=False)
|
||||
# Defense in depth: the underlying tool MUST be in the session's
|
||||
# scoped deferrable catalog. resolve_underlying_call() only checks
|
||||
# that the name is deferrable in the global registry; this gate
|
||||
# additionally rejects any tool the session was not granted, so a
|
||||
# restricted session can never invoke an out-of-scope tool through
|
||||
# the bridge even if the catalog scoping above regressed.
|
||||
_scoped_deferrable = _ts_mod.scoped_deferrable_names(current_defs)
|
||||
if underlying_name not in _scoped_deferrable:
|
||||
return json.dumps({
|
||||
"error": (
|
||||
f"'{underlying_name}' is not available in this session. "
|
||||
"Use tool_search to find tools you can call."
|
||||
),
|
||||
}, ensure_ascii=False)
|
||||
# Recurse with the underlying tool. All hooks fire against the
|
||||
# real tool name. The bridge is invisible to hooks by design.
|
||||
return handle_function_call(
|
||||
@ -871,6 +907,8 @@ def handle_function_call(
|
||||
user_task=user_task,
|
||||
enabled_tools=enabled_tools,
|
||||
skip_pre_tool_call_hook=skip_pre_tool_call_hook,
|
||||
enabled_toolsets=enabled_toolsets,
|
||||
disabled_toolsets=disabled_toolsets,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user