From a5768a4ebefb0e52cb3d64e2ef4e8a338ff33da5 Mon Sep 17 00:00:00 2001 From: yu-xin-c <2182712990@qq.com> Date: Mon, 15 Jun 2026 01:14:21 +0800 Subject: [PATCH] fix(tools): reject unknown builtin parameters --- nanobot/agent/tools/base.py | 18 +++++++++++++++++- nanobot/agent/tools/schema.py | 9 ++++++++- tests/tools/test_tool_registry.py | 22 ++++++++++++++++++++++ tests/tools/test_tool_validation.py | 20 ++++++++++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 0bdff2d8..da715dc5 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -84,9 +84,16 @@ class Schema(ABC): for k in schema.get("required", []): if k not in val: errors.append(f"missing required {Schema.subpath(path, k)}") + additional = schema.get("additionalProperties", True) for k, v in val.items(): if k in props: errors.extend(Schema.validate_json_schema_value(v, props[k], Schema.subpath(path, k))) + elif additional is False: + errors.append(f"unexpected parameter {Schema.subpath(path, k)}") + elif isinstance(additional, dict): + errors.extend( + Schema.validate_json_schema_value(v, additional, Schema.subpath(path, k)) + ) if t == "array": if "minItems" in schema and len(val) < schema["minItems"]: errors.append(f"{label} must have at least {schema['minItems']} items") @@ -193,7 +200,16 @@ class Tool(ABC): if not isinstance(obj, dict): return obj props = schema.get("properties", {}) - return {k: self._cast_value(v, props[k]) if k in props else v for k, v in obj.items()} + additional = schema.get("additionalProperties") + casted: dict[str, Any] = {} + for k, v in obj.items(): + if k in props: + casted[k] = self._cast_value(v, props[k]) + elif isinstance(additional, dict): + casted[k] = self._cast_value(v, additional) + else: + casted[k] = v + return casted def cast_params(self, params: dict[str, Any]) -> dict[str, Any]: """Apply safe schema-driven casts before validation.""" diff --git a/nanobot/agent/tools/schema.py b/nanobot/agent/tools/schema.py index 2b7016d7..e590368a 100644 --- a/nanobot/agent/tools/schema.py +++ b/nanobot/agent/tools/schema.py @@ -222,11 +222,18 @@ def tool_parameters_schema( *, required: list[str] | None = None, description: str = "", + additional_properties: bool | dict[str, Any] | None = False, **properties: Any, ) -> dict[str, Any]: - """Build root tool parameters ``{"type": "object", "properties": ...}`` for :meth:`Tool.parameters`.""" + """Build root tool parameters ``{"type": "object", "properties": ...}`` for :meth:`Tool.parameters`. + + Built-in tools default to strict parameter objects so misspelled tool-call + arguments are reported before execution instead of being silently ignored. + Pass ``additional_properties=None`` to omit the JSON Schema keyword. + """ return ObjectSchema( required=required, description=description, + additional_properties=additional_properties, **properties, ).to_json_schema() diff --git a/tests/tools/test_tool_registry.py b/tests/tools/test_tool_registry.py index 7e9dbb35..7d6bdaaf 100644 --- a/tests/tools/test_tool_registry.py +++ b/tests/tools/test_tool_registry.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any from nanobot.agent.tools.base import Tool +from nanobot.agent.tools.filesystem import ReadFileTool from nanobot.agent.tools.registry import ToolRegistry @@ -235,6 +236,27 @@ def test_prepare_call_other_tools_keep_generic_object_validation() -> None: ) +async def test_registry_rejects_unknown_builtin_tool_parameters(tmp_path) -> None: + (tmp_path / "sample.txt").write_text("one\ntwo\nthree\n", encoding="utf-8") + registry = ToolRegistry() + registry.register( + ReadFileTool( + workspace=tmp_path, + allowed_dir=tmp_path, + restrict_to_workspace=True, + ) + ) + + result = await registry.execute( + "read_file", + {"path": "sample.txt", "line_limit": 1}, + ) + + assert "Invalid parameters" in result + assert "unexpected parameter line_limit" in result + assert "one" not in result + + def test_get_definitions_returns_cached_result() -> None: registry = ToolRegistry() registry.register(_FakeTool("read_file")) diff --git a/tests/tools/test_tool_validation.py b/tests/tools/test_tool_validation.py index 6a775df7..344fd3d0 100644 --- a/tests/tools/test_tool_validation.py +++ b/tests/tools/test_tool_validation.py @@ -125,6 +125,7 @@ def test_schema_classes_equivalent_to_sample_tool_parameters() -> None: required=["tag"], ), required=["query", "count"], + additional_properties=None, ) assert built == SampleTool().parameters @@ -195,6 +196,25 @@ def test_validate_params_ignores_unknown_fields() -> None: assert errors == [] +def test_tool_parameters_schema_rejects_unknown_fields_by_default() -> None: + tool = DecoratedSampleTool() + errors = tool.validate_params({"query": "hi", "count": 2, "extra": "x"}) + assert errors == ["unexpected parameter extra"] + + +def test_validate_params_validates_typed_additional_properties() -> None: + schema = { + "type": "object", + "properties": {}, + "additionalProperties": {"type": "integer"}, + } + tool = CastTestTool(schema) + + errors = tool.validate_params({"extra": "2"}) + + assert errors == ["extra should be integer"] + + async def test_registry_returns_validation_error() -> None: reg = ToolRegistry() reg.register(SampleTool())