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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user