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
+38
View File
@@ -58,10 +58,48 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
else []
),
"attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [],
**_agent_context(db, user, chat),
**audio_service.template_flags(db, user),
}
def _agent_context(db: DBSession, user: User, chat: Chat | None) -> dict:
"""What the composer and the chat header need to know about agent chats.
`agent_profiles` is empty unless every one of the conditions holds -- the
feature is on, the reader may run commands, and they have a usable
connection -- which is what makes the picker appear only when choosing it
would lead anywhere.
"""
from lembas.db.models import SshProfile
from lembas.services.agent import policy as agent_policy
profiles: list[SshProfile] = []
if settings_store.agents(db).get("enabled") and permissions.has(db, user, "tools.agent"):
profiles = list(
db.scalars(
select(SshProfile)
.where(SshProfile.owner_id == user.id, SshProfile.enabled.is_(True))
.order_by(SshProfile.name)
)
)
current = None
if chat is not None and chat.ssh_profile_id:
current = db.get(SshProfile, chat.ssh_profile_id)
if current is not None and current.owner_id != user.id:
current = None
return {
"agent_profiles": profiles,
"agent_profile": current,
"agent_modes": [
(m, agent_policy.MODE_LABELS[m], agent_policy.MODE_HINTS[m])
for m in agent_policy.MODES
],
}
def sidebar_context(db: DBSession, user: User) -> dict:
"""Folder tree plus the chats that belong to no folder.