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
@@ -6,23 +6,55 @@
answer) and from the stored message afterwards, so the sources behind an
answer stay in the transcript rather than vanishing when the stream ends.
EVERYTHING in here comes from a search provider and is untrusted, exactly as
much as model output is. Jinja autoescaping covers the text; the URL is
checked separately, because `is_linkable` is the only thing standing between
a result carrying a javascript: URL and an anchor pointing at it.
EVERYTHING in here is third-party text and is untrusted, exactly as much as
model output is -- a search provider's results, an administrator's HTTP tool
relaying whatever it was pointed at, an MCP server's reply. Jinja autoescaping
covers it. Two things are handled separately: the URL, because `is_linkable`
is the only thing standing between a result carrying a javascript: URL and an
anchor pointing at it, and `event.text`, which is rendered as preformatted
text and deliberately NOT through services/markdown.py -- markdown is the one
path allowed to emit HTML, and this is the last content that should be given
it.
`kind` says how to label the event. Rows written before it existed have none,
so web_search reads as a search and everything else falls to the generic
branch: an old notes_search event used to claim it had searched the web.
#}
{% for event in tool_events %}
{% set kind = event.kind or ('search' if event.name == 'web_search' else 'tool') %}
<details class="tool-activity {{ 'tool-activity--error' if event.status == 'error' }}">
<summary class="tool-activity__summary">
{{ icon("globe", "icon--sm tool-activity__icon") }}
{% if kind == 'search' %}
{{ icon("globe", "icon--sm tool-activity__icon") }}
{% elif kind == 'custom' %}
{{ icon("link", "icon--sm tool-activity__icon") }}
{% elif kind == 'mcp' %}
{{ icon("server", "icon--sm tool-activity__icon") }}
{% else %}
{{ icon("sparkle", "icon--sm tool-activity__icon") }}
{% endif %}
<span class="tool-activity__label">
{% if event.status == "error" %}
Web search failed
{% elif event.query %}
Searched the web for “{{ event.query }}”
{% if kind == 'search' %}
{% if event.status == "error" %}
Web search failed
{% elif event.query %}
Searched the web for “{{ event.query }}”
{% else %}
Searched the web
{% endif %}
{% else %}
Searched the web
{% set label = event.label or event.name %}
{% if event.status == "error" %}
{{ label }} failed
{% else %}
{{ label }}
{% endif %}
{% if event.query %}
<span class="tool-activity__count">· {{ event.query }}</span>
{% endif %}
{% endif %}
{% if event.results %}
<span class="tool-activity__count">
· {{ event.results | length }} result{{ '' if event.results | length == 1 else 's' }}
@@ -33,21 +65,29 @@
</summary>
<div class="tool-activity__body">
{% if event.detail and kind != 'search' %}
<p class="tool-result__host">{{ event.detail }}</p>
{% endif %}
{% if event.error %}
<p class="tool-activity__error">{{ event.error }}</p>
{% elif not event.results %}
{% elif not event.results and not event.text %}
<p class="tool-activity__error">Nothing was found.</p>
{% endif %}
{% if event.text %}
<pre class="tool-result__text">{{ event.text }}</pre>
{% endif %}
{% for result in event.results %}
<div class="tool-result">
{% set scheme = result.url.split(":")[0] | lower %}
{% set scheme = (result.url or "").split(":")[0] | lower %}
{% if scheme in ("http", "https") %}
<a class="tool-result__title" href="{{ result.url }}"
target="_blank" rel="noopener noreferrer nofollow">{{ result.title }}</a>
{% else %}
{# Not a link. A search result is third-party text and its URL is not
trusted to be safe to click. #}
{# Not a link. A result is third-party text and its URL is not trusted to
be safe to click. #}
<span class="tool-result__title">{{ result.title }}</span>
{% endif %}
<span class="tool-result__host">{{ result.host }}</span>