Agent chats run commands, and stop to ask first

The four tools an agent chat has -- shell_run, file_read, file_write,
file_list -- and the mode table wired into the loop that decides which of
them stop for approval. Verified end to end against a real Kali container
over SSH: the card shows the command, allowing it runs it there, and the
file it writes is visible from outside.

The mode is enforced in `_authorise`, in the generation loop, server-side,
keyed on each tool's declared risk. Not in the prompt: a model is told
which mode it is in so it behaves sensibly, but everything it reads -- a
web page, a README, the output of the last command -- is untrusted, and a
rule written only into a system message is one a poisoned file can argue
with. Within an agent chat every call goes through the table, including
the built-in ones, because notes_edit writes and Plan mode meaning "look
but do not touch" has to mean that too.

Two things this turned up.

The runners re-check the mode as a backstop, and that backstop refused the
very thing a person had just approved -- the mode says "ask", and asking
was exactly what happened. Approval is now threaded per call, on a copy of
the context, because a round runs its calls together and only some of them
were allowed.

And the harness said nothing at all, because `registry` maps an offered
tool *name* back to a family and did not know the agent tools existed. So
shell_run resolved to no family and the fragment naming the machine, the
directory and the mode was never admitted. The same omission cost custom
tools their guidance once already; there is a test for it now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 00:08:48 +02:00
parent 191394fa08
commit a064407fa7
14 changed files with 1579 additions and 29 deletions
@@ -54,6 +54,38 @@
<input type="hidden" name="temporary" value="true">
{% endif %}
{# Chat or Agent, chosen once. There is no switching afterwards: the
tools offered, the harness and the approval loop all differ, so a
conversation whose earlier turns ran somewhere else is not one
conversation. Only shown when picking Agent would lead anywhere. #}
{% if not chat and agent_profiles %}
<input type="hidden" name="kind" value="chat" id="chat-kind">
<div class="composer__kind" data-agent-picker>
<label class="chip">
<input type="radio" name="kind_choice" value="chat" checked>
<span>{{ icon("chat", "icon--sm") }} Chat</span>
</label>
<label class="chip">
<input type="radio" name="kind_choice" value="agent">
<span>{{ icon("server", "icon--sm") }} Agent</span>
</label>
<span class="composer__kind-agent" hidden>
<select class="select select--sm" name="ssh_profile_id" aria-label="Connection">
{% for profile in agent_profiles %}
<option value="{{ profile.id }}" data-dir="{{ profile.default_dir }}"
{{ 'disabled' if not profile.verified }}>
{{ profile.name }}{{ ' — not checked' if not profile.verified }}
</option>
{% endfor %}
</select>
<input class="input input--sm input--mono" name="project_dir"
value="{{ agent_profiles[0].default_dir }}"
aria-label="Project directory" placeholder="/project">
</span>
</div>
{% endif %}
<div class="composer__row">
{% if can.get("files.upload") %}
{# A menu rather than the file picker straight away: there are four ways
+20
View File
@@ -24,6 +24,26 @@
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
</h1>
{# The mode is the one agent setting that changes mid-chat: it decides
what gets asked about, not what the conversation is. In the header
rather than the settings panel because it is looked at constantly --
it is the difference between being interrupted and not. #}
{% if chat and chat.kind == "agent" %}
<form class="agent-bar" hx-post="/api/chats/{{ chat.id }}" hx-swap="none"
hx-trigger="change">
<span class="agent-bar__where" title="{{ chat.project_dir }}">
{{ icon("server", "icon--sm") }}
{{ agent_profile.name if agent_profile else "connection missing" }}
</span>
<select class="select select--sm" name="agent_mode" aria-label="Mode">
{% for value, label, hint in agent_modes %}
<option value="{{ value }}" title="{{ hint }}"
{{ 'selected' if value == chat.agent_mode }}>{{ label }}</option>
{% endfor %}
</select>
</form>
{% endif %}
<div class="topbar__actions">
{#
A link, not a script: the flag lives in the URL, so it survives a