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:
parent
369075dc95
commit
7427b9d581
@ -1692,6 +1692,8 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
|||||||
session_id=agent.session_id or "",
|
session_id=agent.session_id or "",
|
||||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||||
skip_pre_tool_call_hook=True,
|
skip_pre_tool_call_hook=True,
|
||||||
|
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||||
|
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,55 @@ def _ra():
|
|||||||
return run_agent
|
return run_agent
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_search_scoped_names(agent) -> frozenset:
|
||||||
|
"""Return the deferrable tool names the session may invoke via tool_call.
|
||||||
|
|
||||||
|
The Tool Search unwrap dispatches the underlying tool directly, bypassing
|
||||||
|
the bridge branch (and its scope check) in
|
||||||
|
``model_tools.handle_function_call``. To keep a restricted-toolset session
|
||||||
|
(subagent, kanban worker, curated gateway session) from reaching tools it
|
||||||
|
was never granted, the unwrap validates the underlying name against this
|
||||||
|
set: the deferrable subset of the session's own enabled/disabled toolset
|
||||||
|
scope.
|
||||||
|
|
||||||
|
Result is cached on the agent and refreshed when the tool registry's
|
||||||
|
generation changes (e.g. an MCP server reconnects), so the common case is
|
||||||
|
a dict lookup, not a full tool-defs rebuild on every tool call.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import model_tools
|
||||||
|
from tools import tool_search as _ts
|
||||||
|
from tools.registry import registry as _registry
|
||||||
|
except Exception:
|
||||||
|
return frozenset()
|
||||||
|
|
||||||
|
enabled = getattr(agent, "enabled_toolsets", None)
|
||||||
|
disabled = getattr(agent, "disabled_toolsets", None)
|
||||||
|
cache_key = (
|
||||||
|
getattr(_registry, "_generation", 0),
|
||||||
|
frozenset(enabled) if enabled is not None else None,
|
||||||
|
frozenset(disabled) if disabled is not None else None,
|
||||||
|
)
|
||||||
|
cached = getattr(agent, "_tool_search_scope_cache", None)
|
||||||
|
if cached is not None and cached[0] == cache_key:
|
||||||
|
return cached[1]
|
||||||
|
try:
|
||||||
|
scoped_defs = model_tools.get_tool_definitions(
|
||||||
|
enabled_toolsets=enabled,
|
||||||
|
disabled_toolsets=disabled,
|
||||||
|
quiet_mode=True,
|
||||||
|
skip_tool_search_assembly=True,
|
||||||
|
) or []
|
||||||
|
names = _ts.scoped_deferrable_names(scoped_defs)
|
||||||
|
except Exception:
|
||||||
|
names = frozenset()
|
||||||
|
try:
|
||||||
|
agent._tool_search_scope_cache = (cache_key, names)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
|
def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
|
||||||
"""Execute multiple tool calls concurrently using a thread pool.
|
"""Execute multiple tool calls concurrently using a thread pool.
|
||||||
|
|
||||||
@ -110,13 +159,28 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||||||
# The original tool_call entry on ``tool_call.function`` is left
|
# The original tool_call entry on ``tool_call.function`` is left
|
||||||
# untouched so the conversation transcript and the matching
|
# untouched so the conversation transcript and the matching
|
||||||
# tool_call_id are preserved exactly as the model emitted them.
|
# tool_call_id are preserved exactly as the model emitted them.
|
||||||
|
#
|
||||||
|
# Scope gate: the unwrap dispatches the underlying tool directly
|
||||||
|
# (bypassing the bridge branch in handle_function_call and its
|
||||||
|
# scope check), so we enforce session toolset scope HERE. A tool
|
||||||
|
# the session was not granted is rejected before any checkpoint,
|
||||||
|
# hook, or dispatch fires.
|
||||||
|
_ts_scope_block = None
|
||||||
try:
|
try:
|
||||||
from tools import tool_search as _ts
|
from tools import tool_search as _ts
|
||||||
if function_name == _ts.TOOL_CALL_NAME:
|
if function_name == _ts.TOOL_CALL_NAME:
|
||||||
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
|
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
|
||||||
if not _err and _underlying:
|
if not _err and _underlying:
|
||||||
function_name = _underlying
|
if _underlying in _tool_search_scoped_names(agent):
|
||||||
function_args = _underlying_args
|
function_name = _underlying
|
||||||
|
function_args = _underlying_args
|
||||||
|
else:
|
||||||
|
_ts_scope_block = json.dumps({
|
||||||
|
"error": (
|
||||||
|
f"'{_underlying}' is not available in this session. "
|
||||||
|
"Use tool_search to find tools you can call."
|
||||||
|
),
|
||||||
|
}, ensure_ascii=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -144,21 +208,25 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||||||
|
|
||||||
block_result = None
|
block_result = None
|
||||||
blocked_by_guardrail = False
|
blocked_by_guardrail = False
|
||||||
try:
|
if _ts_scope_block is not None:
|
||||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
# Out-of-scope tool_call: reject before hooks/guardrails/dispatch.
|
||||||
block_message = get_pre_tool_call_block_message(
|
block_result = _ts_scope_block
|
||||||
function_name, function_args, task_id=effective_task_id or "",
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
block_message = None
|
|
||||||
|
|
||||||
if block_message is not None:
|
|
||||||
block_result = json.dumps({"error": block_message}, ensure_ascii=False)
|
|
||||||
else:
|
else:
|
||||||
guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args)
|
try:
|
||||||
if not guardrail_decision.allows_execution:
|
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||||
block_result = agent._guardrail_block_result(guardrail_decision)
|
block_message = get_pre_tool_call_block_message(
|
||||||
blocked_by_guardrail = True
|
function_name, function_args, task_id=effective_task_id or "",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
block_message = None
|
||||||
|
|
||||||
|
if block_message is not None:
|
||||||
|
block_result = json.dumps({"error": block_message}, ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args)
|
||||||
|
if not guardrail_decision.allows_execution:
|
||||||
|
block_result = agent._guardrail_block_result(guardrail_decision)
|
||||||
|
blocked_by_guardrail = True
|
||||||
|
|
||||||
parsed_calls.append((tool_call, function_name, function_args, block_result, blocked_by_guardrail))
|
parsed_calls.append((tool_call, function_name, function_args, block_result, blocked_by_guardrail))
|
||||||
|
|
||||||
@ -517,26 +585,38 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||||||
if not isinstance(function_args, dict):
|
if not isinstance(function_args, dict):
|
||||||
function_args = {}
|
function_args = {}
|
||||||
|
|
||||||
# Tool Search unwrap — see _execute_tool_calls for full rationale.
|
# Tool Search unwrap — see execute_tool_calls_concurrent for full
|
||||||
|
# rationale, including the scope gate (the unwrap dispatches the
|
||||||
|
# underlying tool directly, so session toolset scope is enforced here).
|
||||||
|
_ts_scope_block: Optional[str] = None
|
||||||
try:
|
try:
|
||||||
from tools import tool_search as _ts
|
from tools import tool_search as _ts
|
||||||
if function_name == _ts.TOOL_CALL_NAME:
|
if function_name == _ts.TOOL_CALL_NAME:
|
||||||
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
|
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
|
||||||
if not _err and _underlying:
|
if not _err and _underlying:
|
||||||
function_name = _underlying
|
if _underlying in _tool_search_scoped_names(agent):
|
||||||
function_args = _underlying_args
|
function_name = _underlying
|
||||||
|
function_args = _underlying_args
|
||||||
|
else:
|
||||||
|
_ts_scope_block = (
|
||||||
|
f"'{_underlying}' is not available in this session. "
|
||||||
|
"Use tool_search to find tools you can call."
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Check plugin hooks for a block directive before executing.
|
# Check plugin hooks for a block directive before executing.
|
||||||
_block_msg: Optional[str] = None
|
_block_msg: Optional[str] = None
|
||||||
try:
|
if _ts_scope_block is not None:
|
||||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
_block_msg = _ts_scope_block
|
||||||
_block_msg = get_pre_tool_call_block_message(
|
else:
|
||||||
function_name, function_args, task_id=effective_task_id or "",
|
try:
|
||||||
)
|
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||||
except Exception:
|
_block_msg = get_pre_tool_call_block_message(
|
||||||
pass
|
function_name, function_args, task_id=effective_task_id or "",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
_guardrail_block_decision: ToolGuardrailDecision | None = None
|
_guardrail_block_decision: ToolGuardrailDecision | None = None
|
||||||
if _block_msg is None:
|
if _block_msg is None:
|
||||||
@ -783,6 +863,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||||||
session_id=agent.session_id or "",
|
session_id=agent.session_id or "",
|
||||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||||
skip_pre_tool_call_hook=True,
|
skip_pre_tool_call_hook=True,
|
||||||
|
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||||
|
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||||
)
|
)
|
||||||
_spinner_result = function_result
|
_spinner_result = function_result
|
||||||
except Exception as tool_error:
|
except Exception as tool_error:
|
||||||
@ -803,6 +885,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||||||
session_id=agent.session_id or "",
|
session_id=agent.session_id or "",
|
||||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||||
skip_pre_tool_call_hook=True,
|
skip_pre_tool_call_hook=True,
|
||||||
|
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||||
|
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||||
)
|
)
|
||||||
except Exception as tool_error:
|
except Exception as tool_error:
|
||||||
function_result = f"Error executing tool '{function_name}': {tool_error}"
|
function_result = f"Error executing tool '{function_name}': {tool_error}"
|
||||||
|
|||||||
@ -262,8 +262,8 @@ def _clear_tool_defs_cache() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def get_tool_definitions(
|
def get_tool_definitions(
|
||||||
enabled_toolsets: List[str] = None,
|
enabled_toolsets: Optional[List[str]] = None,
|
||||||
disabled_toolsets: List[str] = None,
|
disabled_toolsets: Optional[List[str]] = None,
|
||||||
quiet_mode: bool = False,
|
quiet_mode: bool = False,
|
||||||
skip_tool_search_assembly: bool = False,
|
skip_tool_search_assembly: bool = False,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
@ -335,8 +335,8 @@ def get_tool_definitions(
|
|||||||
|
|
||||||
|
|
||||||
def _compute_tool_definitions(
|
def _compute_tool_definitions(
|
||||||
enabled_toolsets: List[str] = None,
|
enabled_toolsets: Optional[List[str]] = None,
|
||||||
disabled_toolsets: List[str] = None,
|
disabled_toolsets: Optional[List[str]] = None,
|
||||||
quiet_mode: bool = False,
|
quiet_mode: bool = False,
|
||||||
skip_tool_search_assembly: bool = False,
|
skip_tool_search_assembly: bool = False,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
@ -808,6 +808,8 @@ def handle_function_call(
|
|||||||
user_task: Optional[str] = None,
|
user_task: Optional[str] = None,
|
||||||
enabled_tools: Optional[List[str]] = None,
|
enabled_tools: Optional[List[str]] = None,
|
||||||
skip_pre_tool_call_hook: bool = False,
|
skip_pre_tool_call_hook: bool = False,
|
||||||
|
enabled_toolsets: Optional[List[str]] = None,
|
||||||
|
disabled_toolsets: Optional[List[str]] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Main function call dispatcher that routes calls to the tool registry.
|
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
|
execute_code uses this list to determine which sandbox
|
||||||
tools to generate. Falls back to the process-global
|
tools to generate. Falls back to the process-global
|
||||||
``_last_resolved_tool_names`` for backward compat.
|
``_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:
|
Returns:
|
||||||
Function result as a JSON string.
|
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,
|
# Use skip_tool_search_assembly=True so we see the real catalog,
|
||||||
# not the already-collapsed bridge-only list (the bridge would
|
# not the already-collapsed bridge-only list (the bridge would
|
||||||
# otherwise be searching only itself).
|
# 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(
|
current_defs = get_tool_definitions(
|
||||||
|
enabled_toolsets=enabled_toolsets,
|
||||||
|
disabled_toolsets=disabled_toolsets,
|
||||||
quiet_mode=True, skip_tool_search_assembly=True,
|
quiet_mode=True, skip_tool_search_assembly=True,
|
||||||
) or []
|
) or []
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -860,6 +882,20 @@ def handle_function_call(
|
|||||||
if err or not underlying_name:
|
if err or not underlying_name:
|
||||||
return json.dumps({"error": err or "tool_call could not be resolved"},
|
return json.dumps({"error": err or "tool_call could not be resolved"},
|
||||||
ensure_ascii=False)
|
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
|
# Recurse with the underlying tool. All hooks fire against the
|
||||||
# real tool name. The bridge is invisible to hooks by design.
|
# real tool name. The bridge is invisible to hooks by design.
|
||||||
return handle_function_call(
|
return handle_function_call(
|
||||||
@ -871,6 +907,8 @@ def handle_function_call(
|
|||||||
user_task=user_task,
|
user_task=user_task,
|
||||||
enabled_tools=enabled_tools,
|
enabled_tools=enabled_tools,
|
||||||
skip_pre_tool_call_hook=skip_pre_tool_call_hook,
|
skip_pre_tool_call_hook=skip_pre_tool_call_hook,
|
||||||
|
enabled_toolsets=enabled_toolsets,
|
||||||
|
disabled_toolsets=disabled_toolsets,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -415,3 +415,124 @@ class TestRegression_OpenClawCron84141:
|
|||||||
assert err is not None
|
assert err is not None
|
||||||
assert "not a deferrable" in err
|
assert "not a deferrable" in err
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegression_ToolsetScoping:
|
||||||
|
"""A restricted-toolset session must not see or invoke out-of-scope tools.
|
||||||
|
|
||||||
|
The bug: the bridge dispatch and the tool_executor unwrap read the
|
||||||
|
catalog from the *global* registry (get_tool_definitions with no
|
||||||
|
toolset scope = "start with everything"), so a session scoped to one
|
||||||
|
MCP server could tool_search the entire process registry and tool_call
|
||||||
|
any plugin tool it was never granted. registry.dispatch() has no
|
||||||
|
enabled_tools gate for non-execute_code tools, so the out-of-scope tool
|
||||||
|
actually ran.
|
||||||
|
|
||||||
|
The fix threads the session's enabled/disabled toolsets into the bridge
|
||||||
|
dispatch (model_tools.handle_function_call) and the executor unwrap
|
||||||
|
(agent.tool_executor), scoping both the searchable catalog and the
|
||||||
|
invocable set to the session's own toolsets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _register(name, toolset):
|
||||||
|
from tools.registry import registry
|
||||||
|
|
||||||
|
def _handler(args, task_id=None, **kw):
|
||||||
|
return json.dumps({"ok": True, "tool": name})
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
name=name,
|
||||||
|
handler=_handler,
|
||||||
|
schema=_td(name, f"desc for {name}", {"repo": {"type": "string"}}),
|
||||||
|
toolset=toolset,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_catalog_is_scoped_to_session_toolsets(self):
|
||||||
|
import model_tools
|
||||||
|
|
||||||
|
for i in range(12):
|
||||||
|
self._register(f"mcp_scoped_gh_{i}", "mcp-scoped-gh")
|
||||||
|
self._register("scoped_oos_plugin", "scopedoosplugin")
|
||||||
|
|
||||||
|
# tool_search scoped to the github toolset must not count the
|
||||||
|
# out-of-scope plugin tool (or any of the host registry).
|
||||||
|
result = model_tools.handle_function_call(
|
||||||
|
function_name="tool_search",
|
||||||
|
function_args={"query": "mcp_scoped_gh", "limit": 5},
|
||||||
|
enabled_toolsets=["mcp-scoped-gh"],
|
||||||
|
)
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed["total_available"] == 12, (
|
||||||
|
f"expected scoped catalog of 12, got {parsed['total_available']} "
|
||||||
|
"— catalog leaked tools outside the session's toolsets"
|
||||||
|
)
|
||||||
|
hit_names = {m["name"] for m in parsed["matches"]}
|
||||||
|
assert "scoped_oos_plugin" not in hit_names
|
||||||
|
|
||||||
|
def test_tool_call_rejects_out_of_scope_tool(self):
|
||||||
|
import model_tools
|
||||||
|
|
||||||
|
self._register("mcp_inscope_gh_op", "mcp-inscope-gh")
|
||||||
|
self._register("inscope_oos_plugin", "inscopeoosplugin")
|
||||||
|
|
||||||
|
# Out-of-scope plugin tool: rejected even though it is registered
|
||||||
|
# and deferrable in the global registry.
|
||||||
|
rejected = json.loads(model_tools.handle_function_call(
|
||||||
|
function_name="tool_call",
|
||||||
|
function_args={"name": "inscope_oos_plugin", "arguments": {}},
|
||||||
|
enabled_toolsets=["mcp-inscope-gh"],
|
||||||
|
))
|
||||||
|
assert "error" in rejected
|
||||||
|
assert "not available in this session" in rejected["error"]
|
||||||
|
|
||||||
|
# In-scope tool: dispatches normally.
|
||||||
|
ok = json.loads(model_tools.handle_function_call(
|
||||||
|
function_name="tool_call",
|
||||||
|
function_args={"name": "mcp_inscope_gh_op", "arguments": {"repo": "a/b"}},
|
||||||
|
enabled_toolsets=["mcp-inscope-gh"],
|
||||||
|
))
|
||||||
|
assert ok.get("ok") is True
|
||||||
|
assert ok.get("tool") == "mcp_inscope_gh_op"
|
||||||
|
|
||||||
|
def test_bridge_dispatch_does_not_pollute_global_resolved_names(self):
|
||||||
|
import model_tools
|
||||||
|
|
||||||
|
self._register("mcp_pollute_op_0", "mcp-pollute")
|
||||||
|
self._register("mcp_pollute_op_1", "mcp-pollute")
|
||||||
|
|
||||||
|
# Establish the scoped session global.
|
||||||
|
model_tools.get_tool_definitions(
|
||||||
|
enabled_toolsets=["mcp-pollute"], quiet_mode=True,
|
||||||
|
)
|
||||||
|
before = set(model_tools._last_resolved_tool_names)
|
||||||
|
assert "terminal" not in before
|
||||||
|
|
||||||
|
# A scoped tool_search call must not widen the process-global
|
||||||
|
# _last_resolved_tool_names to the whole registry (which would leak
|
||||||
|
# core/sandbox tools into execute_code's fallback).
|
||||||
|
model_tools.handle_function_call(
|
||||||
|
function_name="tool_search",
|
||||||
|
function_args={"query": "pollute"},
|
||||||
|
enabled_toolsets=["mcp-pollute"],
|
||||||
|
)
|
||||||
|
after = set(model_tools._last_resolved_tool_names)
|
||||||
|
assert "terminal" not in after, (
|
||||||
|
"bridge dispatch polluted _last_resolved_tool_names with "
|
||||||
|
"out-of-scope tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_scoped_deferrable_names_helper(self):
|
||||||
|
from tools.tool_search import scoped_deferrable_names
|
||||||
|
|
||||||
|
self._register("mcp_helper_op", "mcp-helper")
|
||||||
|
import model_tools
|
||||||
|
defs = model_tools.get_tool_definitions(
|
||||||
|
enabled_toolsets=["mcp-helper"],
|
||||||
|
quiet_mode=True,
|
||||||
|
skip_tool_search_assembly=True,
|
||||||
|
)
|
||||||
|
names = scoped_deferrable_names(defs)
|
||||||
|
assert "mcp_helper_op" in names
|
||||||
|
# core tools are never deferrable
|
||||||
|
assert "terminal" not in names
|
||||||
|
|
||||||
|
|||||||
@ -657,6 +657,26 @@ def dispatch_tool_describe(args: Dict[str, Any],
|
|||||||
}, ensure_ascii=False)
|
}, 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]]:
|
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).
|
"""Parse a ``tool_call`` invocation into (underlying_name, args, error_msg).
|
||||||
|
|
||||||
@ -711,4 +731,5 @@ __all__ = [
|
|||||||
"dispatch_tool_search",
|
"dispatch_tool_search",
|
||||||
"dispatch_tool_describe",
|
"dispatch_tool_describe",
|
||||||
"resolve_underlying_call",
|
"resolve_underlying_call",
|
||||||
|
"scoped_deferrable_names",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -139,6 +139,13 @@ to any progressive-disclosure design, not specific to this implementation:
|
|||||||
tool-defs list every assembly — no session-keyed `Map`. This avoids
|
tool-defs list every assembly — no session-keyed `Map`. This avoids
|
||||||
the class of bug where a stored catalog drifts out of sync with the
|
the class of bug where a stored catalog drifts out of sync with the
|
||||||
live tool registry.
|
live tool registry.
|
||||||
|
- **The catalog is scoped to the session's toolsets.** `tool_search`,
|
||||||
|
`tool_describe`, and `tool_call` only ever see and invoke tools the
|
||||||
|
session was actually granted. A subagent, kanban worker, or gateway
|
||||||
|
session restricted to a subset of toolsets cannot use the bridge to
|
||||||
|
discover or call a tool outside that subset — the deferred catalog is
|
||||||
|
the deferrable slice of the session's own enabled/disabled toolsets,
|
||||||
|
not the whole process registry.
|
||||||
- **No JS sandbox.** Hermes uses the simpler "structured tools" mode
|
- **No JS sandbox.** Hermes uses the simpler "structured tools" mode
|
||||||
(search / describe / call as plain functions). The JS-sandbox "code
|
(search / describe / call as plain functions). The JS-sandbox "code
|
||||||
mode" some other implementations offer is a large surface area; we
|
mode" some other implementations offer is a large surface area; we
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user