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 6a849dc1ec
commit b6aab8de55
14 changed files with 1579 additions and 29 deletions
+80 -4
View File
@@ -14,7 +14,15 @@ from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
from lembas.db.models import (
KIND_AGENT,
KIND_CHAT,
ROLE_ASSISTANT,
ROLE_USER,
Chat,
Message,
User,
)
from lembas.db.session import session_scope
from lembas.security import permissions
from lembas.services import audio as audio_service
@@ -24,8 +32,9 @@ from lembas.services import files as files_service
from lembas.services import generation as generation_service
from lembas.services import metrics as metrics_service
from lembas.services import prompts as prompts_service
from lembas.services import sse
from lembas.services import settings_store, sse
from lembas.services import tools as tools_service
from lembas.services.agent import policy as agent_policy
from lembas.services.markdown import escape_text, render_markdown
from lembas.web.templating import render, templates
@@ -54,8 +63,16 @@ def _new_chat(
folder_id: str = "",
model_id: str = "",
temporary: bool = False,
kind: str = KIND_CHAT,
ssh_profile_id: str = "",
project_dir: str = "",
) -> Chat:
"""Create a chat row, resolving which model it should use."""
"""Create a chat row, resolving which model it should use.
An agent chat's connection is settled here and never again. That is the
lock: the harness, the tools offered and the approval loop all differ, so a
conversation whose earlier turns ran somewhere else is not one conversation.
"""
chosen = None
if model_id:
match = next(
@@ -66,12 +83,16 @@ def _new_chat(
if chosen is None:
chosen = chat_service.default_model(db, user)
profile = _agent_target(db, user, kind, ssh_profile_id)
chat = Chat(
user_id=user.id,
folder_id=folder_id or None,
model_id=chosen[0] if chosen else "",
connection_id=chosen[1] if chosen else None,
temporary=temporary,
kind=KIND_AGENT if profile is not None else KIND_CHAT,
ssh_profile_id=profile.id if profile is not None else None,
project_dir=(project_dir.strip() or profile.default_dir) if profile is not None else "",
)
db.add(chat)
db.commit()
@@ -87,6 +108,9 @@ async def start_chat(
folder_id: str = Form(""),
model_id: str = Form(""),
temporary: bool = Form(False),
kind: str = Form(KIND_CHAT),
ssh_profile_id: str = Form(""),
project_dir: str = Form(""),
) -> Response:
"""Create a chat from its first message.
@@ -100,7 +124,14 @@ async def start_chat(
return Response(status_code=status.HTTP_204_NO_CONTENT)
chat = _new_chat(
db, user, folder_id=folder_id, model_id=model_id, temporary=temporary
db,
user,
folder_id=folder_id,
model_id=model_id,
temporary=temporary,
kind=kind,
ssh_profile_id=ssh_profile_id,
project_dir=project_dir,
)
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
@@ -116,6 +147,32 @@ async def start_chat(
return response
def _agent_target(db: DBSession, user: User, kind: str, profile_id: str):
"""The connection an agent chat is being pointed at, or None.
Every "no" collapses to None and the chat is an ordinary one: not asked
for, no permission, the feature off, or a profile that is not this person's.
Refusing outright would be worse -- somebody whose permission was withdrawn
between opening the composer and sending would lose the message.
"""
from lembas.db.models import SshProfile
from lembas.security import permissions
if kind != KIND_AGENT or not profile_id:
return None
if not permissions.has(db, user, "tools.agent"):
return None
if not settings_store.agents(db).get("enabled"):
return None
profile = db.get(SshProfile, profile_id)
# Ownership re-checked rather than trusted from the form: an id in a POST is
# not an authorisation, and these are credentials to somebody's machine.
if profile is None or profile.owner_id != user.id or not profile.enabled:
return None
return profile
# There is deliberately no route that creates an empty chat. Starting one is
# navigation to /chat (optionally ?model=...), and the row is written by
# /start when the first message is actually sent.
@@ -751,6 +808,25 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
if "folder_id" in form:
chat.folder_id = str(form["folder_id"]) or None
# The mode is the one agent field that changes mid-chat: it decides what
# gets asked about, not what the conversation is.
if "agent_mode" in form:
wanted = str(form["agent_mode"]).strip()
if wanted in agent_policy.MODES:
chat.agent_mode = wanted
# And these are the ones that never do. Refused rather than ignored: a form
# that quietly did nothing would look like a bug from the outside, and
# without the refusal a crafted POST would repoint a conversation at another
# machine halfway through.
for locked in ("kind", "ssh_profile_id", "project_dir"):
if locked in form:
raise HTTPException(
status.HTTP_409_CONFLICT,
"A chat's connection is fixed when it is created. Start a new "
"chat to work somewhere else.",
)
model_id = str(form.get("model_id", "")).strip()
if model_id:
+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.