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:
@@ -0,0 +1,124 @@
|
||||
"""Rendering what a tool did.
|
||||
|
||||
The block is written from four places and read from stored rows written by
|
||||
earlier versions, so it has to render anything shaped roughly like an event --
|
||||
and everything in it is third-party text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from lembas.web.templating import templates
|
||||
|
||||
|
||||
def _render(*events, live: bool = False) -> str:
|
||||
return templates.get_template("chat/_tool_activity.html").render(
|
||||
{"tool_events": list(events), "live": live}
|
||||
)
|
||||
|
||||
|
||||
def test_a_search_still_says_it_searched_the_web():
|
||||
html = _render(
|
||||
{
|
||||
"name": "web_search",
|
||||
"kind": "search",
|
||||
"query": "mallorn",
|
||||
"status": "ok",
|
||||
"results": [
|
||||
{
|
||||
"title": "Mallorn",
|
||||
"url": "https://a.test/m",
|
||||
"host": "a.test",
|
||||
"snippet": "A tree.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert "Searched the web for “mallorn”" in html
|
||||
assert '<a class="tool-result__title" href="https://a.test/m"' in html
|
||||
assert "1 result" in html
|
||||
|
||||
|
||||
def test_a_library_tool_no_longer_claims_to_have_searched_the_web():
|
||||
"""Stored rows predate `kind`, and every one of them used to render a globe
|
||||
and "Searched the web for <the note title>"."""
|
||||
html = _render({"name": "notes_search", "query": "shopping", "status": "ok", "results": []})
|
||||
assert "Searched the web" not in html
|
||||
assert "notes_search" in html
|
||||
|
||||
|
||||
def test_a_custom_tool_is_named_and_its_host_shown():
|
||||
html = _render(
|
||||
{
|
||||
"name": "weather",
|
||||
"kind": "custom",
|
||||
"label": "Weather",
|
||||
"query": "city='Minas Tirith'",
|
||||
"detail": "GET api.test",
|
||||
"status": "ok",
|
||||
"results": [],
|
||||
"text": "Sunny.",
|
||||
}
|
||||
)
|
||||
assert "Weather" in html
|
||||
assert "GET api.test" in html
|
||||
assert "Sunny." in html
|
||||
|
||||
|
||||
def test_a_tools_own_text_is_escaped_and_never_rendered_as_markdown():
|
||||
"""Hard rule 6. A tool's reply is exactly as untrusted as a search result,
|
||||
and markdown is the one path allowed to emit HTML."""
|
||||
html = _render(
|
||||
{
|
||||
"name": "weather",
|
||||
"kind": "custom",
|
||||
"label": "Weather",
|
||||
"status": "ok",
|
||||
"results": [],
|
||||
"text": "<img src=x onerror=alert(1)> [click](javascript:alert(1))",
|
||||
}
|
||||
)
|
||||
assert "<img" not in html
|
||||
assert "<img" in html
|
||||
# The markdown link is shown as the text it is, not turned into an anchor.
|
||||
assert "<a " not in html
|
||||
assert "[click](javascript:alert(1))" in html
|
||||
|
||||
|
||||
def test_a_result_url_that_is_not_http_never_becomes_a_link():
|
||||
html = _render(
|
||||
{
|
||||
"name": "web_search",
|
||||
"kind": "search",
|
||||
"status": "ok",
|
||||
"results": [{"title": "Bad", "url": "javascript:alert(1)", "host": "", "snippet": ""}],
|
||||
}
|
||||
)
|
||||
assert "<a " not in html
|
||||
assert '<span class="tool-result__title">Bad</span>' in html
|
||||
|
||||
|
||||
def test_a_result_with_no_url_at_all_does_not_explode():
|
||||
html = _render(
|
||||
{
|
||||
"name": "notes_search",
|
||||
"status": "ok",
|
||||
"results": [{"title": "A note", "id": "abc"}],
|
||||
}
|
||||
)
|
||||
assert "A note" in html
|
||||
|
||||
|
||||
def test_a_failure_shows_its_reason():
|
||||
html = _render(
|
||||
{
|
||||
"name": "weather",
|
||||
"kind": "custom",
|
||||
"label": "Weather",
|
||||
"status": "error",
|
||||
"error": "HTTP 503",
|
||||
"results": [],
|
||||
}
|
||||
)
|
||||
assert "tool-activity--error" in html
|
||||
assert "Weather failed" in html
|
||||
assert "HTTP 503" in html
|
||||
Reference in New Issue
Block a user