Files
DocsGPT/tests/agents/test_tool_pins.py
T
arc53-machine 4f5cd68771 Keep fixed values, tool type and connected servers out of editors' tool saves
/api/update_tool now checks submitted actions against the stored ones the
same way /api/update_tool_actions does: nothing can be added, and only the
tool's owner can change a fixed value. The tool type (name) can no longer
be changed after creation by anyone.

For API tools, changing who fills an existing header, query or body
parameter, its value, or clearing a stored value is now owner-only on both
/api/update_tool and /api/update_tool_config. Before, an editor could hand
a stored secret query value to the model and read it back in the chat.
The chat now masks every value that came from the stored action rather
than from the model.

A connection-backed MCP tool can no longer be moved to another server or
sign-in method through /api/update_tool or /api/update_tool_config (400,
pointing to /api/mcp_server/save), and a new key saved through
/api/update_tool_config goes to its connection, owner only.
2026-09-29 14:11:36 +01:00

357 lines
16 KiB
Python

"""Fixed ("pinned") tool parameters: the model never sees or overrides them."""
from unittest.mock import Mock
import pytest
from docsgpt.agents.tool_executor import ToolExecutor
def _action(**properties):
return {
"name": "telegram_send_message",
"description": "Send a message",
"active": True,
"parameters": {"type": "object", "properties": properties},
}
def _llm(description="", **extra):
return {"type": "string", "description": description, "filled_by_llm": True, "value": "", **extra}
def _pinned(value, type_="string"):
return {"type": type_, "description": "", "filled_by_llm": False, "value": value}
def _run(executor, tools_dict, call_args, monkeypatch):
monkeypatch.setattr(
"docsgpt.agents.tool_executor.ToolActionParser",
lambda _cls, **kw: Mock(parse_args=Mock(return_value=("t1", "telegram_send_message", call_args))),
)
call = Mock()
call.name = "telegram_send_message"
call.id = "c1"
call.arguments = "{}"
gen = executor.execute(tools_dict, call, "MockLLM")
while True:
try:
next(gen)
except StopIteration as stop:
return stop.value
def _tools(action):
return {
"t1": {
"id": "00000000-0000-0000-0000-000000000001",
"name": "telegram",
"config": {},
"actions": [action],
}
}
@pytest.mark.unit
class TestPinnedValuesAtRunTime:
def test_llm_cannot_override_a_pinned_value(self, mock_tool_manager, monkeypatch):
"""A model that sends the pinned key anyway (a mistake, or a prompt
injection naming another chat) still sends to the pinned chat."""
tool = mock_tool_manager.load_tool.return_value
action = _action(text=_llm(), chat_id=_pinned("111"))
_run(ToolExecutor(user="u"), _tools(action), {"text": "hi", "chat_id": "666"}, monkeypatch)
tool.execute_action.assert_called_once_with("telegram_send_message", text="hi", chat_id="111")
@pytest.mark.parametrize("value", [0, False])
def test_falsy_pins_are_honoured(self, mock_tool_manager, monkeypatch, value):
tool = mock_tool_manager.load_tool.return_value
action = _action(text=_llm(), limit=_pinned(value, "integer"))
_run(ToolExecutor(user="u"), _tools(action), {"text": "hi", "limit": 50}, monkeypatch)
tool.execute_action.assert_called_once_with("telegram_send_message", text="hi", limit=value)
def test_hidden_parameter_without_a_value_is_omitted_even_if_the_llm_sends_it(
self, mock_tool_manager, monkeypatch,
):
"""An empty fixed value means "leave it out" (an OpenAPI optional
parameter); the model was never shown it, so its value is dropped."""
tool = mock_tool_manager.load_tool.return_value
action = _action(text=_llm(), chat_id=_pinned(""))
_run(ToolExecutor(user="u"), _tools(action), {"text": "hi", "chat_id": "666"}, monkeypatch)
tool.execute_action.assert_called_once_with("telegram_send_message", text="hi")
def test_llm_filled_parameter_keeps_its_default_when_omitted(self, mock_tool_manager, monkeypatch):
tool = mock_tool_manager.load_tool.return_value
action = _action(text=_llm(), chat_id=_llm(value="42"))
_run(ToolExecutor(user="u"), _tools(action), {"text": "hi"}, monkeypatch)
tool.execute_action.assert_called_once_with("telegram_send_message", text="hi", chat_id="42")
def test_llm_filled_parameter_takes_the_llm_value(self, mock_tool_manager, monkeypatch):
tool = mock_tool_manager.load_tool.return_value
action = _action(text=_llm(), chat_id=_llm(value="42"))
_run(ToolExecutor(user="u"), _tools(action), {"text": "hi", "chat_id": "7"}, monkeypatch)
tool.execute_action.assert_called_once_with("telegram_send_message", text="hi", chat_id="7")
def test_unknown_llm_arguments_are_dropped(self, mock_tool_manager, monkeypatch):
tool = mock_tool_manager.load_tool.return_value
action = _action(text=_llm())
_run(ToolExecutor(user="u"), _tools(action), {"text": "hi", "token": "x"}, monkeypatch)
tool.execute_action.assert_called_once_with("telegram_send_message", text="hi")
def test_api_tool_pinned_header_is_not_overridden(self, mock_tool_manager, monkeypatch):
executor = ToolExecutor(user="u")
monkeypatch.setattr(
"docsgpt.agents.tool_executor.ToolActionParser",
lambda _cls, **kw: Mock(parse_args=Mock(return_value=("t1", "get_item", {"id": "1", "X-Tenant": "b"}))),
)
tools_dict = {
"t1": {
"id": "00000000-0000-0000-0000-000000000001",
"name": "api_tool",
"config": {
"actions": {
"get_item": {
"name": "get_item",
"url": "https://api.example.com/items",
"method": "GET",
"active": True,
"headers": {"properties": {"X-Tenant": _pinned("a")}},
"query_params": {"properties": {"id": _llm()}},
"body": {"properties": {}},
}
}
},
}
}
call = Mock()
call.name = "get_item"
call.id = "c1"
gen = executor.execute(tools_dict, call, "MockLLM")
while True:
try:
next(gen)
except StopIteration:
break
_, kwargs = mock_tool_manager.load_tool.call_args
assert kwargs["tool_config"]["headers"] == {"X-Tenant": "a"}
assert kwargs["tool_config"]["query_params"] == {"id": "1"}
@pytest.mark.unit
class TestPinnedValuesInTheSchema:
def test_pinned_parameter_is_not_shown_to_the_llm(self):
executor = ToolExecutor(user="u")
action = _action(text=_llm(), chat_id=_pinned("111"), limit=_pinned(0, "integer"))
functions = executor.prepare_tools_for_llm(_tools(action))
params = functions[0]["function"]["parameters"]
assert set(params["properties"]) == {"text"}
def test_pinned_parameter_is_dropped_from_the_required_list(self):
executor = ToolExecutor(user="u")
action = _action(text=_llm(required=True), chat_id={**_pinned("111"), "required": True})
params = executor.prepare_tools_for_llm(_tools(action))[0]["function"]["parameters"]
assert params["required"] == ["text"]
@pytest.mark.unit
class TestPinHelpers:
def test_set_pins_fixes_and_releases_parameters(self):
from docsgpt.agents.tool_pins import set_pins
action = _action(text=_llm(), chat_id=_llm())
pinned = set_pins(action, {"chat_id": "123"})
assert pinned["parameters"]["properties"]["chat_id"] == {
"type": "string", "description": "", "filled_by_llm": False, "value": "123",
}
released = set_pins(pinned, {"chat_id": None})
assert released["parameters"]["properties"]["chat_id"]["filled_by_llm"] is True
assert released["parameters"]["properties"]["chat_id"]["value"] == ""
# The input is not modified.
assert action["parameters"]["properties"]["chat_id"]["filled_by_llm"] is True
def test_set_pins_rejects_unknown_parameters(self):
from docsgpt.agents.tool_pins import set_pins
with pytest.raises(ValueError):
set_pins(_action(text=_llm()), {"token": "x"})
@pytest.mark.parametrize(
"type_, value, expected",
[
("integer", "5", 5),
("integer", 0, 0),
("number", "2.5", 2.5),
("boolean", "false", False),
("boolean", True, True),
("string", 12, "12"),
],
)
def test_coerce_value(self, type_, value, expected):
from docsgpt.agents.tool_pins import coerce_value
assert coerce_value({"type": type_}, value) == expected
@pytest.mark.parametrize(
"type_, value",
[("integer", "five"), ("integer", 2.5), ("boolean", "maybe"), ("string", ""), ("string", None),
("string", {"a": 1}), ("array", "x")],
)
def test_coerce_value_rejects(self, type_, value):
from docsgpt.agents.tool_pins import coerce_value
with pytest.raises(ValueError):
coerce_value({"type": type_}, value)
def test_merge_keeps_schema_and_ignores_type_changes(self):
from docsgpt.agents.tool_pins import merge_submitted_actions
stored = [_action(text=_llm(), chat_id=_llm())]
merged = merge_submitted_actions(stored, [{
"name": "telegram_send_message",
"parameters": {"properties": {"chat_id": {"type": "object", "filled_by_llm": False, "value": "9"}}},
}], may_change_pins=True)
chat_id = merged[0]["parameters"]["properties"]["chat_id"]
assert chat_id["type"] == "string"
assert chat_id["value"] == "9" and chat_id["filled_by_llm"] is False
def test_merge_refuses_pin_changes_without_permission(self):
from docsgpt.agents.tool_pins import PinChangeRefused, merge_submitted_actions
stored = [_action(text=_llm(), chat_id=_pinned("111"))]
with pytest.raises(PinChangeRefused):
merge_submitted_actions(stored, [{
"name": "telegram_send_message",
"parameters": {"properties": {"chat_id": {"filled_by_llm": True}}},
}], may_change_pins=False)
# Resending the stored values unchanged is fine.
merged = merge_submitted_actions(stored, [{
"name": "telegram_send_message",
"active": False,
"parameters": {"properties": {"chat_id": {"filled_by_llm": False, "value": "111"}}},
}], may_change_pins=False)
assert merged[0]["active"] is False
def test_carry_pins(self):
from docsgpt.agents.tool_pins import carry_pins
old = _action(q=_llm(), team=_pinned("ENG"))
fresh = _action(q={"type": "string", "filled_by_llm": True, "value": ""}, team={"type": "string"})
carried = carry_pins(old, fresh)
assert carried["parameters"]["properties"]["team"]["value"] == "ENG"
assert carried["parameters"]["properties"]["team"]["filled_by_llm"] is False
@pytest.mark.unit
class TestArgumentsShownForACall:
"""What the chat shows for a call is what is sent, not what the model asked."""
def _pause(self, executor, action, call_args, monkeypatch):
monkeypatch.setattr(
"docsgpt.agents.tool_executor.ToolActionParser",
lambda _cls, **kw: Mock(parse_args=Mock(return_value=("t1", "telegram_send_message", call_args))),
)
call = Mock()
call.name = "telegram_send_message"
call.id = "c1"
return executor.check_pause(_tools(action), call, "MockLLM")
def test_approval_card_marks_a_fixed_value_without_revealing_it(self, monkeypatch):
"""A value the owner fixed may be a secret, and the chat is shown to
whoever runs the agent: the card says it is fixed, not what it is."""
from docsgpt.agents.tool_pins import FIXED_MASK
action = {**_action(text=_llm(), chat_id=_pinned("111")), "require_approval": True}
pending = self._pause(ToolExecutor(user="u"), action, {"text": "hi", "chat_id": "666"}, monkeypatch)
assert pending["pause_type"] == "awaiting_approval"
assert pending["sent_arguments"] == {"text": "hi", "chat_id": FIXED_MASK}
# What the model asked stays as it was: resuming replays it to the model.
assert pending["arguments"] == {"text": "hi", "chat_id": "666"}
def test_no_separate_arguments_when_nothing_changes(self, monkeypatch):
action = {**_action(text=_llm()), "require_approval": True}
pending = self._pause(ToolExecutor(user="u"), action, {"text": "hi"}, monkeypatch)
assert "sent_arguments" not in pending
def test_a_finished_call_records_what_was_sent_and_keeps_it(self, mock_tool_manager, monkeypatch):
from docsgpt.agents.tool_pins import FIXED_MASK
executor = ToolExecutor(user="u")
action = _action(text=_llm(), chat_id=_pinned("111"))
_run(executor, _tools(action), {"text": "hi", "chat_id": "666"}, monkeypatch)
recorded = executor.tool_calls[-1]
assert recorded["sent_arguments"] == {"text": "hi", "chat_id": FIXED_MASK}
assert recorded["arguments"] == {"text": "hi", "chat_id": "666"}
# Saved with the conversation, so a reload shows the same.
(saved,) = executor.get_truncated_tool_calls()
assert saved["sent_arguments"] == {"text": "hi", "chat_id": FIXED_MASK}
def test_a_value_the_connection_sets_is_shown(self, monkeypatch):
"""Telegram's default chat is the account's own setting, not a secret."""
from docsgpt.agents.tool_pins import sent_arguments
action = _action(text=_llm(), chat_id=_llm())
assert sent_arguments(action, {"text": "hi", "chat_id": "666"}, {"chat_id": "111"}) == {
"text": "hi", "chat_id": "111",
}
def test_fixed_query_and_body_values_and_headers_stay_hidden(self):
from docsgpt.agents.tool_pins import FIXED_MASK, sent_arguments
action = {
"headers": {"properties": {"Authorization": _pinned("Bearer secret")}},
"query_params": {"properties": {"id": _llm(), "api_key": _pinned("sk-query")}},
"body": {"properties": {"token": _pinned("sk-body")}},
}
shown = sent_arguments(action, {"id": "1"})
assert shown == {"id": "1", "api_key": FIXED_MASK, "token": FIXED_MASK}
assert "secret" not in str(shown) and "sk-" not in str(shown)
@pytest.mark.unit
class TestStoredValuesNeverShown:
"""A value the model did not send came from the owner's stored config,
which for an api_tool query parameter is a decrypted secret."""
def test_a_stored_default_the_model_did_not_send_is_masked(self):
from docsgpt.agents.tool_pins import FIXED_MASK, sent_arguments
action = {
"query_params": {"properties": {"id": _llm(), "token": _llm(value="q-secret")}},
"body": {"properties": {"note": _llm(value="b-secret")}},
}
shown = sent_arguments(action, {"id": "1"})
assert shown == {"id": "1", "token": FIXED_MASK, "note": FIXED_MASK}
# What the model itself sent is its own and shows as it is.
assert sent_arguments(action, {"id": "1", "token": "mine"})["token"] == "mine"
def test_a_restored_api_tool_secret_is_not_recorded_with_the_call(self, mock_tool_manager, monkeypatch):
from docsgpt.api.user.tools.routes import _seal_api_tool_secrets
config = _seal_api_tool_secrets({"actions": {"get_item": {
"name": "get_item", "url": "https://api.example.com/items", "method": "GET", "active": True,
"headers": {"properties": {}},
"query_params": {"properties": {"id": _llm(), "token": _llm(value="q-secret")}},
"body": {"properties": {}},
}}}, {}, "owner")
tools_dict = {"t1": {"id": "00000000-0000-0000-0000-000000000001", "user_id": "owner",
"name": "api_tool", "config": config}}
monkeypatch.setattr(
"docsgpt.agents.tool_executor.ToolActionParser",
lambda _cls, **kw: Mock(parse_args=Mock(return_value=("t1", "get_item", {"id": "1"}))),
)
call = Mock()
call.name = "get_item"
call.id = "c1"
executor = ToolExecutor(user="u")
events = []
gen = executor.execute(tools_dict, call, "MockLLM")
while True:
try:
events.append(next(gen))
except StopIteration:
break
# The call still sends the value; the chat never shows it.
_, kwargs = mock_tool_manager.load_tool.call_args
assert kwargs["tool_config"]["query_params"]["token"] == "q-secret"
assert "q-secret" not in str(events)
assert "q-secret" not in str(executor.get_truncated_tool_calls())