fix(agents): validate LLMAgent tool dispatch against declared tools list #63

Closed
opened 2026-06-29 06:59:30 +00:00 by hurui200320 · 1 comment
Member

Metadata

  • Commit Message: fix(agents): validate tool name against declared tools list before dispatch
  • Branch: bugfix/m2-llmagent-tool-dispatch-validation

Background and context

The multi-turn tool-call loop introduced in commit 227e2fe (issue #59) dispatches tool calls returned by the LLM by spawning a transient ToolAgent whose tools list contains only the tool name the LLM requested. However, there is no check that the requested tool name is actually present in the LLMAgent's own self._lc_tools — the list built from the actor's tools: config at construction time.

ToolAgent.__init__ validates the requested name against builtin_tools (the eight hardcoded built-ins registered at startup), not against the actor's configured tools: list. Because all eight standard built-ins are unconditionally accepted, an LLM that has been prompt-injected can call file_read, file_write, http_request, or any other built-in regardless of which tools the actor config actually declared.

Current behavior

When the LLM returns a tool call, LLMAgent executes (in src/cleveractors/agents/llm.py, repeated in each of the three tool-round branches):

tool_config: dict[str, Any] = {
    "tools": [{"name": tool_name}],   # tool_name comes directly from the LLM response
    "safe_mode": not parent_unsafe,
    "allow_shell": self.config.get("allow_shell", False),
    "exec_python": self.config.get("exec_python", False),
    "timeout": self.config.get("timeout", 1),
}
agent = _TA(name=f"_tc_{call_id}", config=tool_config, ...)

tool_name is taken verbatim from tc.get("name", "") or fn_def.get("name", "") with no membership check against self._lc_tools.

Expected behavior

Before spawning the transient ToolAgent, LLMAgent must verify that tool_name appears in the set of names declared in self._lc_tools. If the name is absent, the tool call should produce a ToolMessage with an appropriate error (e.g. "Tool 'X' is not available") and the loop should continue so the LLM can recover, rather than silently executing an undeclared tool.

Suggested guard (same logic applies to all three loop branches):

_declared_names = {
    t.get("function", {}).get("name", "") for t in (self._lc_tools or [])
}
if tool_name not in _declared_names:
    messages.append(
        ToolMessage(
            content=f"Tool '{tool_name}' is not available.",
            tool_call_id=call_id,
        )
    )
    continue

Acceptance criteria

  1. When the LLM requests a tool not present in self._lc_tools, a ToolMessage with an error string is appended and the loop continues; the undeclared tool is never executed.
  2. When the LLM requests a tool that is present in self._lc_tools, execution proceeds as before — no regression.
  3. The guard is applied consistently in all three tool-dispatch branches (main loop, budget-exhausted synthesis branch, and the streaming-variant branch if applicable).
  4. A Behave scenario is added to features/llm_agent_tool_calling.feature covering the undeclared-tool case.
  5. All existing nox sessions pass (unit_tests, coverage_report ≥ 97%, typecheck, lint).

Subtasks

  • Identify all three tool-dispatch branches in src/cleveractors/agents/llm.py that construct the transient ToolAgent
  • Add the _declared_names membership check before each ToolAgent instantiation
  • Add a Behave scenario: Scenario: LLMAgent rejects tool calls for tools not in the declared list
  • Run nox -s unit_tests and confirm new scenario passes
  • Run nox -s coverage_report and confirm ≥ 97%
  • Run nox (all default sessions) and confirm fully green

Definition of Done

This issue is complete when:

  • All subtasks above are completed and checked off.
  • A Git commit is created where the first line matches the Commit Message in Metadata exactly.
  • The commit is pushed to the branch matching the Branch in Metadata exactly.
  • The commit is submitted as a PR to master, reviewed, and merged.
## Metadata - **Commit Message:** `fix(agents): validate tool name against declared tools list before dispatch` - **Branch:** `bugfix/m2-llmagent-tool-dispatch-validation` ## Background and context The multi-turn tool-call loop introduced in commit `227e2fe` (issue #59) dispatches tool calls returned by the LLM by spawning a transient `ToolAgent` whose `tools` list contains only the tool name the LLM requested. However, there is no check that the requested tool name is actually present in the `LLMAgent`'s own `self._lc_tools` — the list built from the actor's `tools:` config at construction time. `ToolAgent.__init__` validates the requested name against `builtin_tools` (the eight hardcoded built-ins registered at startup), not against the actor's configured `tools:` list. Because all eight standard built-ins are unconditionally accepted, an LLM that has been prompt-injected can call `file_read`, `file_write`, `http_request`, or any other built-in regardless of which tools the actor config actually declared. ## Current behavior When the LLM returns a tool call, `LLMAgent` executes (in `src/cleveractors/agents/llm.py`, repeated in each of the three tool-round branches): ```python tool_config: dict[str, Any] = { "tools": [{"name": tool_name}], # tool_name comes directly from the LLM response "safe_mode": not parent_unsafe, "allow_shell": self.config.get("allow_shell", False), "exec_python": self.config.get("exec_python", False), "timeout": self.config.get("timeout", 1), } agent = _TA(name=f"_tc_{call_id}", config=tool_config, ...) ``` `tool_name` is taken verbatim from `tc.get("name", "")` or `fn_def.get("name", "")` with no membership check against `self._lc_tools`. ## Expected behavior Before spawning the transient `ToolAgent`, `LLMAgent` must verify that `tool_name` appears in the set of names declared in `self._lc_tools`. If the name is absent, the tool call should produce a `ToolMessage` with an appropriate error (e.g. `"Tool 'X' is not available"`) and the loop should continue so the LLM can recover, rather than silently executing an undeclared tool. Suggested guard (same logic applies to all three loop branches): ```python _declared_names = { t.get("function", {}).get("name", "") for t in (self._lc_tools or []) } if tool_name not in _declared_names: messages.append( ToolMessage( content=f"Tool '{tool_name}' is not available.", tool_call_id=call_id, ) ) continue ``` ## Acceptance criteria 1. When the LLM requests a tool not present in `self._lc_tools`, a `ToolMessage` with an error string is appended and the loop continues; the undeclared tool is never executed. 2. When the LLM requests a tool that is present in `self._lc_tools`, execution proceeds as before — no regression. 3. The guard is applied consistently in all three tool-dispatch branches (main loop, budget-exhausted synthesis branch, and the streaming-variant branch if applicable). 4. A Behave scenario is added to `features/llm_agent_tool_calling.feature` covering the undeclared-tool case. 5. All existing `nox` sessions pass (`unit_tests`, `coverage_report` ≥ 97%, `typecheck`, `lint`). ## Subtasks - [ ] Identify all three tool-dispatch branches in `src/cleveractors/agents/llm.py` that construct the transient `ToolAgent` - [ ] Add the `_declared_names` membership check before each `ToolAgent` instantiation - [ ] Add a Behave scenario: `Scenario: LLMAgent rejects tool calls for tools not in the declared list` - [ ] Run `nox -s unit_tests` and confirm new scenario passes - [ ] Run `nox -s coverage_report` and confirm ≥ 97% - [ ] Run `nox` (all default sessions) and confirm fully green ## Definition of Done This issue is complete when: - All subtasks above are completed and checked off. - A Git commit is created where the first line matches the Commit Message in Metadata exactly. - The commit is pushed to the branch matching the Branch in Metadata exactly. - The commit is submitted as a PR to master, reviewed, and merged.
hurui200320 added this to the v2.1.0 milestone 2026-06-29 07:16:38 +00:00
Member

I do not think this report is actually true, because there is a safeguard in: src/cleveractors/tool.py method _execute_tool:

        if tool_name not in self.tools and tool_name not in dict_tool_names:
            logger.error("Tool '%s' not in allowed list for %s", tool_name, self.name)
            raise ExecutionError(f"Tool '{tool_name}' not in allowed tools list")

it just needs to be adjusted to:

        if tool_name not in dict_tool_names:
            logger.error("Tool '%s' not in allowed list for %s", tool_name, self.name)
            raise ExecutionError(f"Tool '{tool_name}' not in allowed tools list")

but for clear feedback to the main agent i've kept the suggested code snippet.

I do not think this report is actually true, because there is a safeguard in: `src/cleveractors/tool.py` method _execute_tool: ``` if tool_name not in self.tools and tool_name not in dict_tool_names: logger.error("Tool '%s' not in allowed list for %s", tool_name, self.name) raise ExecutionError(f"Tool '{tool_name}' not in allowed tools list") ``` it just needs to be adjusted to: ``` if tool_name not in dict_tool_names: logger.error("Tool '%s' not in allowed list for %s", tool_name, self.name) raise ExecutionError(f"Tool '{tool_name}' not in allowed tools list") ``` but for clear feedback to the main agent i've kept the suggested code snippet.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
cleveragents/cleveractors-core#63
No description provided.