Custom HTTP tools an administrator defines

A row in custom_tools becomes a ToolDef like any built-in, offered beside
the thirteen. The registry had to stop being an import-time constant for
that: `resolve_tools` now returns the schemas *and* the runners together,
carried to the loop on the ToolContext.

That closes a hole on the way. `run_tool` looked names up in the global
REGISTRY with no reference to what had been offered, so a model naming a
tool its chat was gated out of -- a family switched off, a permission the
reader lacks -- had it run anyway. The resolved set is now authoritative.

Arguments come from a model, so an argument may fill a hole but never move
the target: the scheme and host of a URL template are literal, values are
escaped for where they land, and the origin is pinned afterwards. Every
redirect hop is checked the way services/fetch.py checks one, and the
secret is dropped if a hop leaves the origin it was issued for.

Also fixes the tool-activity block claiming every library tool had
"searched the web", which it has done since the second family landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 16:26:47 +02:00
parent d9f274ec1a
commit bc84fec21d
26 changed files with 2771 additions and 60 deletions
+51
View File
@@ -166,6 +166,57 @@ def _context(**kwargs):
return tools_service.ToolContext(owner_id="someone", **kwargs)
# --- The offer and the runner travel together --------------------------------
def test_the_resolved_set_carries_the_runners_with_the_schemas(db, user_id):
"""A tool that is a database row is not reachable through the import-time
registry, so the resolution has to travel with the offer."""
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat = _chat_with(db, user_id, capabilities={"tools": True})
resolved = tools_service.resolve_tools(db, chat, _user(db, user_id))
assert _names(resolved.schemas) == set(resolved.by_name)
assert all(callable(tool.run) for tool in resolved.defs)
# The old accessor is the same set, so nothing that only wants schemas moved.
assert resolved.schemas == tools_service.enabled_tools(db, chat, _user(db, user_id))
async def test_a_tool_that_was_not_offered_is_refused(db, user_id):
"""The lookup is against what was offered, not against everything that
exists. A model naming a tool its chat was gated out of used to have it run,
because only the offer was ever filtered."""
from lembas.db.models import User
from lembas.services.library import notes as notes_service
owner = db.get(User, user_id)
note = notes_service.create(db, owner=owner, title="Keep me", body="...")
chat = _chat_with(db, user_id, capabilities={"tools": True, "tool_notes": False})
resolved = tools_service.resolve_tools(db, chat, owner)
context = tools_service.context_for(db, owner, chat, tools=resolved)
outcome = await tools_service.run_tool(
context, "notes_delete", json.dumps({"id": note.id})
)
assert outcome.event["status"] == "error"
assert notes_service.get(db, note.id, owner) is not None
async def test_a_context_with_no_toolset_still_finds_the_builtins(monkeypatch):
"""None means nobody resolved a set. An empty dict does not -- it means
nothing was offered, and is authoritative."""
async def fake_run(_config, query, *, limit=None):
return [SearchResult("A title", "https://a.test", "a snippet")]
monkeypatch.setattr("lembas.services.search.run", fake_run)
unresolved = await tools_service.run_tool(_context(), "web_search", '{"query": "x"}')
assert unresolved.event["status"] == "ok"
empty = await tools_service.run_tool(_context(tools={}), "web_search", '{"query": "x"}')
assert empty.event["status"] == "error"
# --- Knowledge is scoped to the chat's bases ---------------------------------
async def test_knowledge_search_is_limited_to_the_attached_bases(db, user_id):
""""Answer from the contracts folder" is a different question from "answer