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
@@ -0,0 +1,21 @@
{% from "_macros.html" import icon %}
{#
The result of calling one tool by hand.
What comes back is whatever the endpoint chose to send, which makes it exactly
as untrusted as a search result or model output. It is shown escaped, inside a
<pre>, and never rendered as Markdown.
#}
{% if error %}
<div class="alert alert--error">{{ icon("warning", "icon--sm") }} <span>{{ error }}</span></div>
{% else %}
<div class="alert alert--success">
{{ icon("check", "icon--sm") }}
<span>{{ detail or tool.slug }} answered.</span>
</div>
{% endif %}
{% if outcome %}
<p class="field__hint">This is what the model would read back:</p>
<pre class="tool-result__text">{{ outcome.content }}</pre>
{% endif %}